Keep agent profile settings valid across providers (#3331)
* fix(app): keep profile modes scoped to providers Applying a profile through sequential field setters allowed a provider change to race mode persistence, poisoning modeless provider preferences with another provider's mode. Apply profiles as one form transition and heal invalid provider-scoped mode preferences when profiles are used. * test(app): cover profile features across providers
This commit is contained in:
@@ -0,0 +1,225 @@
|
||||
import { expect, test, type Page } from "../support/fixtures";
|
||||
import path from "node:path";
|
||||
import type { FormPreferences } from "@/create-agent-preferences/preferences";
|
||||
import {
|
||||
applyProfileFromPicker,
|
||||
drillIntoProvider,
|
||||
openModelPicker,
|
||||
seedAgentProfiles,
|
||||
seedModelProvider,
|
||||
} from "../support/helpers/agent-profiles";
|
||||
import { gotoAppShell } from "../support/helpers/app";
|
||||
import { daemonWsRoutePattern } from "../support/helpers/daemon-port";
|
||||
import {
|
||||
openGlobalNewWorkspaceComposer,
|
||||
selectNewWorkspaceProject,
|
||||
submitNewWorkspacePrompt,
|
||||
} from "../support/helpers/new-workspace";
|
||||
import { seedWorkspace } from "../support/helpers/seed-client";
|
||||
import { waitForSidebarHydration } from "../support/helpers/workspace-ui";
|
||||
|
||||
const CREATE_AGENT_PREFERENCES_KEY = "@paseo:create-agent-preferences";
|
||||
const MODELESS_PROVIDER = "modeless-profile-e2e";
|
||||
const MODELESS_MODEL = "pi-profile-model";
|
||||
|
||||
type WebSocketMessage = string | Buffer;
|
||||
|
||||
interface CreateAgentRequestMessage {
|
||||
type: "create_agent_request";
|
||||
config?: {
|
||||
provider?: unknown;
|
||||
modeId?: unknown;
|
||||
featureValues?: unknown;
|
||||
};
|
||||
}
|
||||
|
||||
function getSessionMessage(message: WebSocketMessage): Record<string, unknown> | null {
|
||||
const rawMessage = typeof message === "string" ? message : message.toString("utf8");
|
||||
try {
|
||||
const envelope = JSON.parse(rawMessage) as { type?: unknown; message?: unknown };
|
||||
return envelope.type === "session" && envelope.message && typeof envelope.message === "object"
|
||||
? (envelope.message as Record<string, unknown>)
|
||||
: null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function recordAndBlockCreateAgentRequest(page: Page): Promise<{
|
||||
waitForRequest(): Promise<CreateAgentRequestMessage>;
|
||||
}> {
|
||||
let resolveRequest: ((message: CreateAgentRequestMessage) => void) | null = null;
|
||||
const request = new Promise<CreateAgentRequestMessage>((resolve) => {
|
||||
resolveRequest = resolve;
|
||||
});
|
||||
|
||||
await page.routeWebSocket(daemonWsRoutePattern(), (ws) => {
|
||||
const server = ws.connectToServer();
|
||||
ws.onMessage((message) => {
|
||||
const sessionMessage = getSessionMessage(message);
|
||||
if (sessionMessage?.type === "create_agent_request") {
|
||||
resolveRequest?.(sessionMessage as unknown as CreateAgentRequestMessage);
|
||||
return;
|
||||
}
|
||||
server.send(message);
|
||||
});
|
||||
server.onMessage((message) => ws.send(message));
|
||||
});
|
||||
|
||||
return { waitForRequest: () => request };
|
||||
}
|
||||
|
||||
async function seedPoisonedModelessPreference(page: Page): Promise<void> {
|
||||
await page.addInitScript(
|
||||
({ preferencesKey, provider, model }) => {
|
||||
localStorage.setItem(
|
||||
preferencesKey,
|
||||
JSON.stringify({
|
||||
provider,
|
||||
providerPreferences: {
|
||||
[provider]: { model, mode: "full-access" },
|
||||
mock: { model: "ten-second-stream", mode: "load-test" },
|
||||
},
|
||||
} satisfies FormPreferences),
|
||||
);
|
||||
},
|
||||
{
|
||||
preferencesKey: CREATE_AGENT_PREFERENCES_KEY,
|
||||
provider: MODELESS_PROVIDER,
|
||||
model: MODELESS_MODEL,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
async function readProviderModePreference(page: Page, provider: string): Promise<unknown> {
|
||||
return page.evaluate(
|
||||
({ preferencesKey, providerId }) => {
|
||||
const raw = localStorage.getItem(preferencesKey);
|
||||
if (!raw) return null;
|
||||
const preferences = JSON.parse(raw) as FormPreferences;
|
||||
return preferences.providerPreferences?.[providerId]?.mode ?? null;
|
||||
},
|
||||
{ preferencesKey: CREATE_AGENT_PREFERENCES_KEY, providerId: provider },
|
||||
);
|
||||
}
|
||||
|
||||
test.describe("Agent profiles repair modeless provider preferences", () => {
|
||||
test.describe.configure({ timeout: 240_000 });
|
||||
|
||||
test("applying a profile heals a stale mode before returning to Pi", async ({ page }) => {
|
||||
const workspace = await seedWorkspace({ repoPrefix: "profile-modeless-preferences-" });
|
||||
const profiles = await seedAgentProfiles([
|
||||
{
|
||||
id: "agent_profile_modeless_repair",
|
||||
name: "Approval work",
|
||||
provider: "mock",
|
||||
model: "one-minute-stream",
|
||||
modeId: "approval-test",
|
||||
},
|
||||
]);
|
||||
const provider = await seedModelProvider({
|
||||
id: MODELESS_PROVIDER,
|
||||
label: "Pi profile test",
|
||||
extends: "pi",
|
||||
command: [process.execPath, path.resolve("e2e/fixtures/fake-pi-rpc.mjs")],
|
||||
models: [
|
||||
{
|
||||
id: MODELESS_MODEL,
|
||||
label: "Pi profile model",
|
||||
description: "Modeless profile regression model",
|
||||
},
|
||||
],
|
||||
});
|
||||
await seedPoisonedModelessPreference(page);
|
||||
const createAgentRecorder = await recordAndBlockCreateAgentRequest(page);
|
||||
|
||||
try {
|
||||
await gotoAppShell(page);
|
||||
await waitForSidebarHydration(page);
|
||||
await openGlobalNewWorkspaceComposer(page);
|
||||
await selectNewWorkspaceProject(page, {
|
||||
projectKey: workspace.projectKey,
|
||||
projectDisplayName: workspace.projectDisplayName,
|
||||
});
|
||||
|
||||
await openModelPicker(page);
|
||||
await applyProfileFromPicker(page, "Approval work");
|
||||
|
||||
await expect
|
||||
.poll(() => readProviderModePreference(page, MODELESS_PROVIDER), { timeout: 10_000 })
|
||||
.toBeNull();
|
||||
await expect
|
||||
.poll(() => readProviderModePreference(page, "mock"), { timeout: 10_000 })
|
||||
.toBe("approval-test");
|
||||
|
||||
await openModelPicker(page);
|
||||
await drillIntoProvider(page, MODELESS_PROVIDER);
|
||||
await page.getByRole("button", { name: /Pi profile model/ }).click();
|
||||
|
||||
await submitNewWorkspacePrompt(page, "Create a Pi agent after repairing preferences.");
|
||||
const createAgentRequest = await createAgentRecorder.waitForRequest();
|
||||
expect(createAgentRequest).toMatchObject({
|
||||
config: { provider: MODELESS_PROVIDER },
|
||||
});
|
||||
expect(createAgentRequest.config).not.toHaveProperty("modeId");
|
||||
} finally {
|
||||
await workspace.cleanup();
|
||||
await provider.restore();
|
||||
await profiles.restore();
|
||||
}
|
||||
});
|
||||
|
||||
test("cross-provider profile features survive immediate workspace creation", async ({ page }) => {
|
||||
const workspace = await seedWorkspace({ repoPrefix: "profile-feature-transition-" });
|
||||
const featureProviderId = "profile-feature-e2e";
|
||||
const featureModelId = "claude-opus-5";
|
||||
const profiles = await seedAgentProfiles([
|
||||
{
|
||||
id: "agent_profile_feature_transition",
|
||||
name: "Fast profile",
|
||||
provider: featureProviderId,
|
||||
model: featureModelId,
|
||||
modeId: "bypassPermissions",
|
||||
featureValues: { fast_mode: true },
|
||||
},
|
||||
]);
|
||||
const provider = await seedModelProvider({
|
||||
id: featureProviderId,
|
||||
label: "Profile feature test",
|
||||
models: [
|
||||
{
|
||||
id: featureModelId,
|
||||
label: "Profile feature model",
|
||||
description: "Cross-provider feature regression model",
|
||||
},
|
||||
],
|
||||
});
|
||||
const createAgentRecorder = await recordAndBlockCreateAgentRequest(page);
|
||||
|
||||
try {
|
||||
await gotoAppShell(page);
|
||||
await waitForSidebarHydration(page);
|
||||
await openGlobalNewWorkspaceComposer(page);
|
||||
await selectNewWorkspaceProject(page, {
|
||||
projectKey: workspace.projectKey,
|
||||
projectDisplayName: workspace.projectDisplayName,
|
||||
});
|
||||
|
||||
await openModelPicker(page);
|
||||
await applyProfileFromPicker(page, "Fast profile");
|
||||
await submitNewWorkspacePrompt(page, "Create an agent with profile features.");
|
||||
|
||||
const createAgentRequest = await createAgentRecorder.waitForRequest();
|
||||
expect(createAgentRequest).toMatchObject({
|
||||
config: {
|
||||
provider: featureProviderId,
|
||||
featureValues: { fast_mode: true },
|
||||
},
|
||||
});
|
||||
} finally {
|
||||
await workspace.cleanup();
|
||||
await provider.restore();
|
||||
await profiles.restore();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,38 @@
|
||||
import readline from "node:readline";
|
||||
|
||||
const lines = readline.createInterface({ input: process.stdin });
|
||||
|
||||
for await (const line of lines) {
|
||||
const command = JSON.parse(line);
|
||||
let data = {};
|
||||
if (command.type === "get_available_models") {
|
||||
data = {
|
||||
models: [
|
||||
{
|
||||
provider: "e2e",
|
||||
id: "pi-profile-model",
|
||||
name: "Pi profile model",
|
||||
},
|
||||
],
|
||||
};
|
||||
} else if (command.type === "get_state") {
|
||||
data = {
|
||||
sessionId: "e2e-pi-session",
|
||||
thinkingLevel: "medium",
|
||||
isStreaming: false,
|
||||
isCompacting: false,
|
||||
messageCount: 0,
|
||||
pendingMessageCount: 0,
|
||||
};
|
||||
}
|
||||
|
||||
process.stdout.write(
|
||||
`${JSON.stringify({
|
||||
id: command.id,
|
||||
type: "response",
|
||||
command: command.type,
|
||||
success: true,
|
||||
data,
|
||||
})}\n`,
|
||||
);
|
||||
}
|
||||
@@ -104,21 +104,24 @@ export interface SeededProviderModel {
|
||||
* list is what avoids running anything: replacement models skip catalog
|
||||
* discovery, and Claude's static modes mean the provider never spawns. The
|
||||
* command only has to resolve for the availability probe, hence `node`.
|
||||
* Providers with dynamic catalogs can pass a small RPC fixture as `command`.
|
||||
*/
|
||||
export async function seedModelProvider(input: {
|
||||
id: string;
|
||||
label: string;
|
||||
models: SeededProviderModel[];
|
||||
extends?: "claude" | "pi";
|
||||
command?: string[];
|
||||
}): Promise<HostSeed> {
|
||||
const client = await connectAgentProfilesClient();
|
||||
await client.patchDaemonConfig({
|
||||
providers: {
|
||||
[input.id]: {
|
||||
extends: "claude",
|
||||
extends: input.extends ?? "claude",
|
||||
label: input.label,
|
||||
description: `${input.label} test provider`,
|
||||
enabled: true,
|
||||
command: ["node"],
|
||||
command: input.command ?? ["node"],
|
||||
models: input.models,
|
||||
},
|
||||
},
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
* here.
|
||||
*/
|
||||
export type { AgentProfile } from "@getpaseo/protocol/messages";
|
||||
export type { MaterializedAgentProfile } from "./internal/materialize-profile";
|
||||
export { useAgentProfiles } from "./internal/use-agent-profiles";
|
||||
export {
|
||||
useAgentProfilePicker,
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { AgentProfile } from "@getpaseo/protocol/messages";
|
||||
import { materializeAgentProfile, toAgentConfigApply } from "./materialize-profile";
|
||||
import {
|
||||
materializeAgentProfile,
|
||||
reconcileMaterializedProfileMode,
|
||||
toAgentConfigApply,
|
||||
} from "./materialize-profile";
|
||||
|
||||
function profile(overrides: Partial<AgentProfile> = {}): AgentProfile {
|
||||
return {
|
||||
@@ -87,3 +91,29 @@ describe("toAgentConfigApply", () => {
|
||||
).not.toHaveProperty("provider");
|
||||
});
|
||||
});
|
||||
|
||||
describe("reconcileMaterializedProfileMode", () => {
|
||||
it("waits when the current provider mode catalog is unknown", () => {
|
||||
expect(
|
||||
reconcileMaterializedProfileMode(materializeAgentProfile(profile({ modeId: "plan" })), null),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("drops a profile mode removed from the current provider catalog", () => {
|
||||
expect(
|
||||
reconcileMaterializedProfileMode(
|
||||
materializeAgentProfile(profile({ modeId: "removed-mode" })),
|
||||
["plan", "default"],
|
||||
),
|
||||
).toMatchObject({ modeId: "" });
|
||||
});
|
||||
|
||||
it("preserves a profile mode still offered by the provider", () => {
|
||||
expect(
|
||||
reconcileMaterializedProfileMode(materializeAgentProfile(profile({ modeId: "plan" })), [
|
||||
"plan",
|
||||
"default",
|
||||
]),
|
||||
).toMatchObject({ modeId: "plan" });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -29,6 +29,19 @@ export function materializeAgentProfile(profile: AgentProfile): MaterializedAgen
|
||||
};
|
||||
}
|
||||
|
||||
export function reconcileMaterializedProfileMode(
|
||||
profile: MaterializedAgentProfile,
|
||||
availableModeIds: readonly string[] | null,
|
||||
): MaterializedAgentProfile | null {
|
||||
if (availableModeIds === null) {
|
||||
return null;
|
||||
}
|
||||
if (!profile.modeId || availableModeIds.includes(profile.modeId)) {
|
||||
return profile;
|
||||
}
|
||||
return { ...profile, modeId: "" };
|
||||
}
|
||||
|
||||
/**
|
||||
* The payload for a running agent. Omitted fields are left alone by the daemon,
|
||||
* so a profile that names no mode does not reset the agent's mode. Provider is
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
import { useCallback, useMemo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { AgentProvider } from "@getpaseo/protocol/agent-types";
|
||||
import type { AgentProfile } from "@getpaseo/protocol/messages";
|
||||
import { mergeCreateAgentSelectionPreferences } from "@/create-agent-preferences/preferences";
|
||||
import { useFormPreferences } from "@/hooks/use-form-preferences";
|
||||
import { useProvidersSnapshot } from "@/hooks/use-providers-snapshot";
|
||||
@@ -9,21 +7,22 @@ import { useSessionStore } from "@/stores/session-store";
|
||||
import { useToast } from "@/contexts/toast-context";
|
||||
import { toErrorMessage } from "@/utils/error-messages";
|
||||
import { showProviderNoticeToast } from "@/utils/provider-notice-toast";
|
||||
import { materializeAgentProfile, toAgentConfigApply } from "./materialize-profile";
|
||||
import {
|
||||
materializeAgentProfile,
|
||||
reconcileMaterializedProfileMode,
|
||||
toAgentConfigApply,
|
||||
type MaterializedAgentProfile,
|
||||
} from "./materialize-profile";
|
||||
import { buildAgentProfileTags } from "./profile-summary";
|
||||
import { useAgentProfiles } from "./use-agent-profiles";
|
||||
|
||||
/** The draft composer's own setters. Applying a profile drives them like a user would. */
|
||||
/** The draft composer owns profile application as one state transition. */
|
||||
export interface DraftAgentProfileControls {
|
||||
selectProvider: (provider: AgentProvider) => void;
|
||||
selectProviderAndModel: (provider: AgentProvider, modelId: string) => void;
|
||||
selectMode: (modeId: string) => void;
|
||||
selectThinkingOption: (thinkingOptionId: string) => void;
|
||||
setFeature?: (featureId: string, value: unknown) => void;
|
||||
applyProfile: (profile: MaterializedAgentProfile) => void;
|
||||
}
|
||||
|
||||
export type AgentProfileApplyTarget =
|
||||
| { kind: "agent"; agentId: string }
|
||||
| { kind: "agent"; agentId: string; availableModeIds: readonly string[] | null }
|
||||
| { kind: "draft"; controls: DraftAgentProfileControls };
|
||||
|
||||
/** Everything the model picker renders for one profile. It never sees the profile itself. */
|
||||
@@ -107,8 +106,7 @@ export function useAgentProfilePicker(
|
||||
);
|
||||
|
||||
const persistSelection = useCallback(
|
||||
(profile: AgentProfile) => {
|
||||
const resolved = materializeAgentProfile(profile);
|
||||
(resolved: MaterializedAgentProfile) => {
|
||||
void updatePreferences((current) =>
|
||||
mergeCreateAgentSelectionPreferences({
|
||||
preferences: current,
|
||||
@@ -135,35 +133,21 @@ export function useAgentProfilePicker(
|
||||
}
|
||||
const resolved = materializeAgentProfile(profile);
|
||||
|
||||
// Persist on both paths, before driving the controls. The draft form drops
|
||||
// its local feature values when the provider changes, so preferences are
|
||||
// the only channel that survives a profile that switches provider.
|
||||
persistSelection(profile);
|
||||
|
||||
if (target.kind === "draft") {
|
||||
const { controls } = target;
|
||||
if (resolved.modelId) {
|
||||
controls.selectProviderAndModel(resolved.provider, resolved.modelId);
|
||||
} else {
|
||||
controls.selectProvider(resolved.provider);
|
||||
}
|
||||
if (resolved.modeId) {
|
||||
controls.selectMode(resolved.modeId);
|
||||
}
|
||||
if (resolved.thinkingOptionId) {
|
||||
controls.selectThinkingOption(resolved.thinkingOptionId);
|
||||
}
|
||||
for (const [featureId, value] of Object.entries(resolved.featureValues)) {
|
||||
controls.setFeature?.(featureId, value);
|
||||
}
|
||||
target.controls.applyProfile(resolved);
|
||||
return;
|
||||
}
|
||||
|
||||
const reconciled = reconcileMaterializedProfileMode(resolved, target.availableModeIds);
|
||||
if (!reconciled) {
|
||||
return;
|
||||
}
|
||||
persistSelection(reconciled);
|
||||
if (!client) {
|
||||
return;
|
||||
}
|
||||
void client
|
||||
.applyAgentConfig(target.agentId, toAgentConfigApply(resolved))
|
||||
.applyAgentConfig(target.agentId, toAgentConfigApply(reconciled))
|
||||
.then((notice) => showProviderNoticeToast(toast, notice))
|
||||
.catch((error) => {
|
||||
console.warn("[useAgentProfilePicker] applyAgentConfig failed", error);
|
||||
|
||||
@@ -81,6 +81,7 @@ import {
|
||||
useAgentProfilePicker,
|
||||
type AgentProfileApplyTarget,
|
||||
type AgentProfilePicker,
|
||||
type DraftAgentProfileControls,
|
||||
} from "@/agent-profiles";
|
||||
import { buildSettingsHostSectionRoute } from "@/utils/host-routes";
|
||||
|
||||
@@ -125,7 +126,6 @@ interface ControlledAgentControlsProps {
|
||||
export interface DraftAgentControlsProps {
|
||||
providerDefinitions: AgentProviderDefinition[];
|
||||
selectedProvider: AgentProvider | null;
|
||||
onSelectProvider: (provider: AgentProvider) => void;
|
||||
modeOptions: AgentMode[];
|
||||
selectedMode: string;
|
||||
onSelectMode: (modeId: string) => void;
|
||||
@@ -139,6 +139,7 @@ export interface DraftAgentControlsProps {
|
||||
thinkingOptions: NonNullable<AgentModelDefinition["thinkingOptions"]>;
|
||||
selectedThinkingOptionId: string;
|
||||
onSelectThinkingOption: (thinkingOptionId: string) => void;
|
||||
onApplyAgentProfile: DraftAgentProfileControls["applyProfile"];
|
||||
features?: AgentFeature[];
|
||||
onSetFeature?: (featureId: string, value: unknown) => void;
|
||||
onDropdownClose?: () => void;
|
||||
@@ -385,6 +386,15 @@ function resolveSnapshotSelectedEntry(
|
||||
return snapshotEntries.find((e) => e.provider === agentProvider) ?? null;
|
||||
}
|
||||
|
||||
function resolveSnapshotModeIds(
|
||||
entry: ReturnType<typeof resolveSnapshotSelectedEntry>,
|
||||
): string[] | null {
|
||||
if (entry?.status !== "ready" || !entry.modes) {
|
||||
return null;
|
||||
}
|
||||
return entry.modes.map((mode) => mode.id);
|
||||
}
|
||||
|
||||
function buildAgentProviderDefinitions(
|
||||
agentProvider: string | undefined,
|
||||
snapshotEntries: ReturnType<typeof useProvidersSnapshot>["entries"],
|
||||
@@ -1550,9 +1560,13 @@ export const AgentControls = memo(function AgentControls({
|
||||
// A running agent is one provider's process, so only that provider's profiles
|
||||
// can apply to it.
|
||||
const profileProviders = useMemo(() => (agentProvider ? [agentProvider] : []), [agentProvider]);
|
||||
const profileModeIds = useMemo(
|
||||
() => resolveSnapshotModeIds(snapshotSelectedEntry),
|
||||
[snapshotSelectedEntry],
|
||||
);
|
||||
const profileTarget = useMemo<AgentProfileApplyTarget>(
|
||||
() => ({ kind: "agent", agentId }),
|
||||
[agentId],
|
||||
() => ({ kind: "agent", agentId, availableModeIds: profileModeIds }),
|
||||
[agentId, profileModeIds],
|
||||
);
|
||||
const agentProfiles = useAgentProfilePicker({
|
||||
serverId,
|
||||
@@ -1692,7 +1706,6 @@ export const AgentControls = memo(function AgentControls({
|
||||
export function DraftAgentControls({
|
||||
providerDefinitions,
|
||||
selectedProvider,
|
||||
onSelectProvider,
|
||||
modeOptions,
|
||||
selectedMode,
|
||||
onSelectMode,
|
||||
@@ -1706,6 +1719,7 @@ export function DraftAgentControls({
|
||||
thinkingOptions,
|
||||
selectedThinkingOptionId,
|
||||
onSelectThinkingOption,
|
||||
onApplyAgentProfile,
|
||||
features,
|
||||
onSetFeature,
|
||||
onDropdownClose,
|
||||
@@ -1742,20 +1756,10 @@ export function DraftAgentControls({
|
||||
() => ({
|
||||
kind: "draft",
|
||||
controls: {
|
||||
selectProvider: onSelectProvider,
|
||||
selectProviderAndModel: onSelectProviderAndModel,
|
||||
selectMode: onSelectMode,
|
||||
selectThinkingOption: onSelectThinkingOption,
|
||||
setFeature: onSetFeature,
|
||||
applyProfile: onApplyAgentProfile,
|
||||
},
|
||||
}),
|
||||
[
|
||||
onSelectMode,
|
||||
onSelectProvider,
|
||||
onSelectProviderAndModel,
|
||||
onSelectThinkingOption,
|
||||
onSetFeature,
|
||||
],
|
||||
[onApplyAgentProfile],
|
||||
);
|
||||
const agentProfiles = useAgentProfilePicker({
|
||||
serverId: modelSelectorServerId,
|
||||
|
||||
@@ -20,7 +20,6 @@ describe("resolveAgentControlsMode", () => {
|
||||
resolveAgentControlsMode({
|
||||
providerDefinitions: [],
|
||||
selectedProvider: "codex",
|
||||
onSelectProvider: () => undefined,
|
||||
modeOptions: [],
|
||||
selectedMode: "",
|
||||
onSelectMode: () => undefined,
|
||||
@@ -34,6 +33,7 @@ describe("resolveAgentControlsMode", () => {
|
||||
thinkingOptions: [],
|
||||
selectedThinkingOptionId: "",
|
||||
onSelectThinkingOption: () => undefined,
|
||||
onApplyAgentProfile: () => undefined,
|
||||
}),
|
||||
).toBe("draft");
|
||||
});
|
||||
|
||||
@@ -22,13 +22,13 @@ export function buildDraftAgentControls(input: {
|
||||
formState: UseAgentFormStateResult;
|
||||
features?: DraftAgentControlsProps["features"];
|
||||
onSetFeature?: DraftAgentControlsProps["onSetFeature"];
|
||||
onApplyAgentProfile: DraftAgentControlsProps["onApplyAgentProfile"];
|
||||
onDropdownClose?: DraftAgentControlsProps["onDropdownClose"];
|
||||
}): DraftAgentControlsProps {
|
||||
const { formState, features, onSetFeature, onDropdownClose } = input;
|
||||
const { formState, features, onSetFeature, onApplyAgentProfile, onDropdownClose } = input;
|
||||
return {
|
||||
providerDefinitions: formState.providerDefinitions,
|
||||
selectedProvider: formState.selectedProvider,
|
||||
onSelectProvider: formState.setProviderFromUser,
|
||||
modeOptions: formState.modeOptions,
|
||||
selectedMode: formState.selectedMode,
|
||||
onSelectMode: formState.setModeFromUser,
|
||||
@@ -42,6 +42,7 @@ export function buildDraftAgentControls(input: {
|
||||
thinkingOptions: formState.availableThinkingOptions,
|
||||
selectedThinkingOptionId: formState.selectedThinkingOptionId,
|
||||
onSelectThinkingOption: formState.setThinkingOptionFromUser,
|
||||
onApplyAgentProfile,
|
||||
features,
|
||||
onSetFeature,
|
||||
onDropdownClose,
|
||||
|
||||
@@ -190,6 +190,7 @@ export function useAgentInputDraft(input: UseAgentInputDraftInput): AgentInputDr
|
||||
features: draftFeatures,
|
||||
featureValues: draftFeatureValues,
|
||||
setFeatureValue: setDraftFeatureValue,
|
||||
applyProfileFeatureValues,
|
||||
} = useDraftAgentFeatures({
|
||||
serverId: formState.selectedServerId,
|
||||
provider: formState.selectedProvider,
|
||||
@@ -200,6 +201,14 @@ export function useAgentInputDraft(input: UseAgentInputDraftInput): AgentInputDr
|
||||
initialFeatureValues: composerOptions?.initialFeatureValues,
|
||||
});
|
||||
|
||||
const applyDraftAgentProfile = useCallback(
|
||||
(profile: Parameters<typeof formState.applyProfileFromUser>[0]) => {
|
||||
formState.applyProfileFromUser(profile);
|
||||
applyProfileFeatureValues(profile.featureValues);
|
||||
},
|
||||
[applyProfileFeatureValues, formState],
|
||||
);
|
||||
|
||||
const commandDraftConfig = useMemo(
|
||||
() =>
|
||||
composerOptions
|
||||
@@ -236,6 +245,7 @@ export function useAgentInputDraft(input: UseAgentInputDraftInput): AgentInputDr
|
||||
formState,
|
||||
features: draftFeatures,
|
||||
onSetFeature: setDraftFeatureValue,
|
||||
onApplyAgentProfile: applyDraftAgentProfile,
|
||||
}),
|
||||
commandDraftConfig,
|
||||
};
|
||||
@@ -246,6 +256,7 @@ export function useAgentInputDraft(input: UseAgentInputDraftInput): AgentInputDr
|
||||
effectiveThinkingOptionId,
|
||||
draftFeatures,
|
||||
draftFeatureValues,
|
||||
applyDraftAgentProfile,
|
||||
formState,
|
||||
setDraftFeatureValue,
|
||||
workingDir,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { CreateAgentPreferencesService } from "./service";
|
||||
import {
|
||||
applyAgentProfilePreferences,
|
||||
mergeCreateAgentSelectionPreferences,
|
||||
mergeProviderPreferences,
|
||||
parseFormPreferences,
|
||||
@@ -133,6 +134,50 @@ describe("create agent preferences", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("erases a saved mode when a complete selection explicitly has no mode", () => {
|
||||
expect(
|
||||
mergeCreateAgentSelectionPreferences({
|
||||
preferences: {
|
||||
provider: "pi",
|
||||
providerPreferences: { pi: { model: "anthropic/sonnet", mode: "full-access" } },
|
||||
},
|
||||
provider: "pi",
|
||||
modelId: "anthropic/sonnet",
|
||||
modeId: null,
|
||||
}),
|
||||
).toEqual({
|
||||
provider: "pi",
|
||||
providerPreferences: { pi: { model: "anthropic/sonnet" } },
|
||||
});
|
||||
});
|
||||
|
||||
it("repairs the previous provider while applying a profile", () => {
|
||||
expect(
|
||||
applyAgentProfilePreferences({
|
||||
preferences: {
|
||||
provider: "pi",
|
||||
providerPreferences: {
|
||||
pi: { model: "anthropic/sonnet", mode: "full-access" },
|
||||
mock: { model: "ten-second-stream", mode: "load-test" },
|
||||
},
|
||||
},
|
||||
previousProvider: "pi",
|
||||
previousProviderModeIds: [],
|
||||
provider: "mock",
|
||||
modelId: "one-minute-stream",
|
||||
modeId: "approval-test",
|
||||
thinkingOptionId: "",
|
||||
featureValues: {},
|
||||
}),
|
||||
).toEqual({
|
||||
provider: "mock",
|
||||
providerPreferences: {
|
||||
pi: { model: "anthropic/sonnet" },
|
||||
mock: { model: "one-minute-stream", mode: "approval-test", featureValues: {} },
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("loads invalid stored preferences as empty preferences", () => {
|
||||
expect(parseFormPreferences({ providerPreferences: { codex: { mode: 42 } } })).toEqual({});
|
||||
});
|
||||
|
||||
@@ -112,7 +112,7 @@ function mergeDefinedRecord<T>(
|
||||
|
||||
function applyProviderPreferenceUpdates(
|
||||
existing: ProviderPreferences,
|
||||
updates: Partial<ProviderPreferences>,
|
||||
updates: Omit<Partial<ProviderPreferences>, "mode"> & { mode?: string | null },
|
||||
): ProviderPreferences {
|
||||
const next: ProviderPreferences = { ...existing };
|
||||
const nextThinkingByModel = mergeDefinedRecord(existing.thinkingByModel, updates.thinkingByModel);
|
||||
@@ -121,7 +121,9 @@ function applyProviderPreferenceUpdates(
|
||||
if (updates.model !== undefined) {
|
||||
next.model = updates.model;
|
||||
}
|
||||
if (updates.mode !== undefined) {
|
||||
if (updates.mode === null) {
|
||||
delete next.mode;
|
||||
} else if (updates.mode !== undefined) {
|
||||
next.mode = updates.mode;
|
||||
}
|
||||
if (nextThinkingByModel !== undefined) {
|
||||
@@ -137,7 +139,7 @@ function applyProviderPreferenceUpdates(
|
||||
export function mergeProviderPreferences(args: {
|
||||
preferences: FormPreferences;
|
||||
provider: AgentProvider;
|
||||
updates: Partial<ProviderPreferences>;
|
||||
updates: Omit<Partial<ProviderPreferences>, "mode"> & { mode?: string | null };
|
||||
}): FormPreferences {
|
||||
const { preferences, provider, updates } = args;
|
||||
const existingProviderPreferences = preferences.providerPreferences ?? {};
|
||||
@@ -175,9 +177,45 @@ export function mergeCreateAgentSelectionPreferences(args: {
|
||||
provider: args.provider,
|
||||
updates: {
|
||||
model: modelId || undefined,
|
||||
mode: modeId || undefined,
|
||||
mode: args.modeId === undefined ? undefined : modeId || null,
|
||||
...(modelId && thinkingOptionId ? { thinkingByModel: { [modelId]: thinkingOptionId } } : {}),
|
||||
...(featureValues.success ? { featureValues: featureValues.data } : {}),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function applyAgentProfilePreferences(args: {
|
||||
preferences: FormPreferences;
|
||||
previousProvider: AgentProvider | null;
|
||||
previousProviderModeIds: readonly string[];
|
||||
provider: AgentProvider;
|
||||
modelId: string;
|
||||
modeId: string;
|
||||
thinkingOptionId: string;
|
||||
featureValues: Record<string, unknown>;
|
||||
}): FormPreferences {
|
||||
let next = args.preferences;
|
||||
if (args.previousProvider) {
|
||||
const previousMode = next.providerPreferences?.[args.previousProvider]?.mode;
|
||||
if (previousMode && !args.previousProviderModeIds.includes(previousMode)) {
|
||||
next = mergeProviderPreferences({
|
||||
preferences: next,
|
||||
provider: args.previousProvider,
|
||||
updates: { mode: null },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return mergeProviderPreferences({
|
||||
preferences: next,
|
||||
provider: args.provider,
|
||||
updates: {
|
||||
model: args.modelId || undefined,
|
||||
mode: args.modeId || null,
|
||||
...(args.modelId && args.thinkingOptionId
|
||||
? { thinkingByModel: { [args.modelId]: args.thinkingOptionId } }
|
||||
: {}),
|
||||
featureValues: args.featureValues,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
} from "@/provider-selection/provider-selection";
|
||||
import { filterSelectableModels } from "@/provider-selection/model-catalog";
|
||||
import { OptimisticFormPreferences } from "@/create-agent-preferences/optimistic-preferences";
|
||||
import { applyAgentProfilePreferences } from "@/create-agent-preferences/preferences";
|
||||
import { useProvidersSnapshot } from "./use-providers-snapshot";
|
||||
import {
|
||||
useFormPreferences,
|
||||
@@ -37,6 +38,7 @@ import {
|
||||
type FormState,
|
||||
type ProviderModelsByProvider,
|
||||
} from "@/provider-selection/resolve-agent-form";
|
||||
import type { MaterializedAgentProfile } from "@/agent-profiles";
|
||||
|
||||
export type { FormInitialValues } from "@/provider-selection/resolve-agent-form";
|
||||
|
||||
@@ -54,7 +56,6 @@ export interface UseAgentFormStateResult {
|
||||
setSelectedServerId: (value: string | null) => void;
|
||||
setSelectedServerIdFromUser: (value: string | null) => void;
|
||||
selectedProvider: AgentProvider | null;
|
||||
setProviderFromUser: (provider: AgentProvider) => void;
|
||||
selectedMode: string;
|
||||
setModeFromUser: (modeId: string) => void;
|
||||
selectedModel: string;
|
||||
@@ -80,6 +81,7 @@ export interface UseAgentFormStateResult {
|
||||
refreshProviderModels: (provider?: AgentProvider) => void;
|
||||
refetchProviderModelsIfStale: () => void;
|
||||
setProviderAndModelFromUser: (provider: AgentProvider, modelId: string) => void;
|
||||
applyProfileFromUser: (profile: MaterializedAgentProfile) => void;
|
||||
clearProviderSelectionFromUser: () => void;
|
||||
workingDirIsEmpty: boolean;
|
||||
persistFormPreferences: () => Promise<void>;
|
||||
@@ -254,11 +256,6 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg
|
||||
}),
|
||||
);
|
||||
|
||||
const reducerStateRef = useRef({ form: formState, userModified });
|
||||
useEffect(() => {
|
||||
reducerStateRef.current = { form: formState, userModified };
|
||||
}, [formState, userModified]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isVisible) {
|
||||
dispatch({ type: "RESET" });
|
||||
@@ -423,27 +420,6 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg
|
||||
dispatch({ type: "SET_SERVER_ID_FROM_USER", value });
|
||||
}, []);
|
||||
|
||||
const setProviderFromUser = useCallback(
|
||||
(provider: AgentProvider) => {
|
||||
if (!selectableProviderDefinitionMap.has(provider)) {
|
||||
return;
|
||||
}
|
||||
const providerModels = allProviderModels.get(provider) ?? null;
|
||||
const providerDef = selectableProviderDefinitionMap.get(provider);
|
||||
const providerPrefs = preferenceOverlayRef.current.current().providerPreferences?.[provider];
|
||||
|
||||
dispatch({
|
||||
type: "SET_PROVIDER_FROM_USER",
|
||||
provider,
|
||||
providerModels,
|
||||
providerDef,
|
||||
providerPrefs,
|
||||
});
|
||||
void updateCurrentPreferences({ provider });
|
||||
},
|
||||
[allProviderModels, selectableProviderDefinitionMap, updateCurrentPreferences],
|
||||
);
|
||||
|
||||
const setProviderAndModelFromUser = useCallback(
|
||||
(provider: AgentProvider, modelId: string) => {
|
||||
if (!selectableProviderDefinitionMap.has(provider)) {
|
||||
@@ -480,10 +456,64 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg
|
||||
dispatch({ type: "CLEAR_PROVIDER_SELECTION_FROM_USER" });
|
||||
}, []);
|
||||
|
||||
const applyProfileFromUser = useCallback(
|
||||
(profile: MaterializedAgentProfile) => {
|
||||
const provider = profile.provider as AgentProvider;
|
||||
if (!selectableProviderDefinitionMap.has(provider)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const previousProvider = formState.provider;
|
||||
const providerDef = selectableProviderDefinitionMap.get(provider);
|
||||
const providerModels = allProviderModels.get(provider) ?? null;
|
||||
const providerPrefs = preferenceOverlayRef.current.current().providerPreferences?.[provider];
|
||||
const action = {
|
||||
type: "APPLY_PROFILE_FROM_USER" as const,
|
||||
provider,
|
||||
modelId: profile.modelId,
|
||||
modeId: profile.modeId,
|
||||
thinkingOptionId: profile.thinkingOptionId,
|
||||
providerDef,
|
||||
providerModels,
|
||||
providerPrefs,
|
||||
};
|
||||
const nextState = resolveAgentForm({ form: formState, userModified, resolution }, action);
|
||||
const previousProviderModeIds = previousProvider
|
||||
? (providerDefinitionMap.get(previousProvider)?.modes.map((mode) => mode.id) ?? [])
|
||||
: [];
|
||||
|
||||
dispatch(action);
|
||||
void updateCurrentPreferences((current) => {
|
||||
const { model, modeId, thinkingOptionId } = nextState.form;
|
||||
return applyAgentProfilePreferences({
|
||||
preferences: current,
|
||||
previousProvider,
|
||||
previousProviderModeIds,
|
||||
provider,
|
||||
modelId: model,
|
||||
modeId,
|
||||
thinkingOptionId,
|
||||
featureValues: profile.featureValues,
|
||||
});
|
||||
}).catch((error) => {
|
||||
console.warn("[useAgentFormState] persist profile preference failed", error);
|
||||
});
|
||||
},
|
||||
[
|
||||
allProviderModels,
|
||||
formState,
|
||||
providerDefinitionMap,
|
||||
resolution,
|
||||
selectableProviderDefinitionMap,
|
||||
updateCurrentPreferences,
|
||||
userModified,
|
||||
],
|
||||
);
|
||||
|
||||
const setModeFromUser = useCallback(
|
||||
(modeId: string) => {
|
||||
dispatch({ type: "SET_MODE_FROM_USER", modeId });
|
||||
const provider = reducerStateRef.current.form.provider;
|
||||
const provider = formState.provider;
|
||||
if (provider) {
|
||||
void updateCurrentPreferences((current) =>
|
||||
mergeSelectedComposerPreferences({
|
||||
@@ -496,12 +526,12 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg
|
||||
);
|
||||
}
|
||||
},
|
||||
[updateCurrentPreferences],
|
||||
[formState.provider, updateCurrentPreferences],
|
||||
);
|
||||
|
||||
const setModelFromUser = useCallback(
|
||||
(modelId: string) => {
|
||||
const provider = reducerStateRef.current.form.provider;
|
||||
const provider = formState.provider;
|
||||
const providerPrefs = provider
|
||||
? preferenceOverlayRef.current.current().providerPreferences?.[provider]
|
||||
: undefined;
|
||||
@@ -525,13 +555,13 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg
|
||||
);
|
||||
}
|
||||
},
|
||||
[availableModels, updateCurrentPreferences],
|
||||
[availableModels, formState.provider, updateCurrentPreferences],
|
||||
);
|
||||
|
||||
const setThinkingOptionFromUser = useCallback(
|
||||
(thinkingOptionId: string) => {
|
||||
dispatch({ type: "SET_THINKING_OPTION_FROM_USER", thinkingOptionId });
|
||||
const { provider, model: modelId } = reducerStateRef.current.form;
|
||||
const { provider, model: modelId } = formState;
|
||||
if (provider && modelId) {
|
||||
void updateCurrentPreferences((current) =>
|
||||
mergeSelectedComposerPreferences({
|
||||
@@ -546,7 +576,7 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg
|
||||
);
|
||||
}
|
||||
},
|
||||
[updateCurrentPreferences],
|
||||
[formState, updateCurrentPreferences],
|
||||
);
|
||||
|
||||
const setWorkingDir = useCallback((value: string) => {
|
||||
@@ -569,8 +599,8 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg
|
||||
);
|
||||
|
||||
const refetchProviderModelsIfStale = useCallback(() => {
|
||||
refetchSnapshotIfStale(reducerStateRef.current.form.provider);
|
||||
}, [refetchSnapshotIfStale]);
|
||||
refetchSnapshotIfStale(formState.provider);
|
||||
}, [formState.provider, refetchSnapshotIfStale]);
|
||||
|
||||
const persistFormPreferences = useCallback(async () => {
|
||||
if (!formState.provider) {
|
||||
@@ -604,7 +634,6 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg
|
||||
setSelectedServerId,
|
||||
setSelectedServerIdFromUser,
|
||||
selectedProvider: formState.provider,
|
||||
setProviderFromUser,
|
||||
selectedMode: formState.modeId,
|
||||
setModeFromUser,
|
||||
selectedModel: formState.model,
|
||||
@@ -630,6 +659,7 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg
|
||||
refreshProviderModels,
|
||||
refetchProviderModelsIfStale,
|
||||
setProviderAndModelFromUser,
|
||||
applyProfileFromUser,
|
||||
clearProviderSelectionFromUser,
|
||||
workingDirIsEmpty,
|
||||
persistFormPreferences,
|
||||
@@ -643,7 +673,6 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg
|
||||
formState.workingDir,
|
||||
setSelectedServerId,
|
||||
setSelectedServerIdFromUser,
|
||||
setProviderFromUser,
|
||||
setModeFromUser,
|
||||
setModelFromUser,
|
||||
setThinkingOptionFromUser,
|
||||
@@ -665,6 +694,7 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg
|
||||
refreshProviderModels,
|
||||
refetchProviderModelsIfStale,
|
||||
setProviderAndModelFromUser,
|
||||
applyProfileFromUser,
|
||||
clearProviderSelectionFromUser,
|
||||
workingDirIsEmpty,
|
||||
persistFormPreferences,
|
||||
|
||||
@@ -145,10 +145,15 @@ export function useDraftAgentFeatures(input: {
|
||||
[provider, updatePreferences],
|
||||
);
|
||||
|
||||
const applyProfileFeatureValues = useCallback((values: Record<string, unknown>) => {
|
||||
setLocalFeatureValues(values);
|
||||
}, []);
|
||||
|
||||
return {
|
||||
features,
|
||||
featureValues: effectiveFeatureValues,
|
||||
isLoading: featuresQuery.isLoading,
|
||||
setFeatureValue,
|
||||
applyProfileFeatureValues,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -47,6 +47,14 @@ const TEST_CLAUDE_DEFINITION: AgentProviderDefinition = {
|
||||
],
|
||||
};
|
||||
|
||||
const TEST_PI_DEFINITION: AgentProviderDefinition = {
|
||||
id: "pi",
|
||||
label: "Pi",
|
||||
description: "Pi test provider",
|
||||
defaultModeId: null,
|
||||
modes: [],
|
||||
};
|
||||
|
||||
const CODEX_MODELS: AgentModelDefinition[] = [
|
||||
{
|
||||
provider: "codex",
|
||||
@@ -970,55 +978,6 @@ describe("resolveAgentForm", () => {
|
||||
});
|
||||
});
|
||||
|
||||
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");
|
||||
});
|
||||
|
||||
it("canonicalizes an aliased preferred model and restores its thinking option", () => {
|
||||
const next = resolveAgentForm(makeState(), {
|
||||
type: "SET_PROVIDER_FROM_USER",
|
||||
provider: "codex",
|
||||
providerModels: ALIASED_CODEX_MODELS,
|
||||
providerDef: TEST_CODEX_DEFINITION,
|
||||
providerPrefs: {
|
||||
model: "gpt-5.3-codex-legacy",
|
||||
thinkingByModel: { "gpt-5.3-codex-legacy": "low" },
|
||||
},
|
||||
});
|
||||
|
||||
expect(next.form.model).toBe("gpt-5.3-codex");
|
||||
expect(next.form.thinkingOptionId).toBe("low");
|
||||
});
|
||||
});
|
||||
|
||||
describe("SET_PROVIDER_AND_MODEL_FROM_USER", () => {
|
||||
it("sets provider, model, and default mode; marks both modified", () => {
|
||||
const state = makeState();
|
||||
@@ -1090,6 +1049,42 @@ describe("resolveAgentForm", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("APPLY_PROFILE_FROM_USER", () => {
|
||||
it("drops a stale saved mode for a modeless profile provider", () => {
|
||||
const next = resolveAgentForm(makeState({ provider: "codex", modeId: "full-access" }), {
|
||||
type: "APPLY_PROFILE_FROM_USER",
|
||||
provider: "pi",
|
||||
modelId: "anthropic/sonnet",
|
||||
modeId: "",
|
||||
thinkingOptionId: "",
|
||||
providerDef: TEST_PI_DEFINITION,
|
||||
providerModels: [{ provider: "pi", id: "anthropic/sonnet", label: "Sonnet" }],
|
||||
providerPrefs: { mode: "full-access" },
|
||||
});
|
||||
|
||||
expect(next.form).toMatchObject({
|
||||
provider: "pi",
|
||||
model: "anthropic/sonnet",
|
||||
modeId: "",
|
||||
});
|
||||
});
|
||||
|
||||
it("restores thinking for the selected model when the profile omits it", () => {
|
||||
const next = resolveAgentForm(makeState(), {
|
||||
type: "APPLY_PROFILE_FROM_USER",
|
||||
provider: "codex",
|
||||
modelId: "gpt-5.3-codex",
|
||||
modeId: "full-access",
|
||||
thinkingOptionId: "",
|
||||
providerDef: TEST_CODEX_DEFINITION,
|
||||
providerModels: CODEX_MODELS,
|
||||
providerPrefs: { thinkingByModel: { "gpt-5.3-codex": "low" } },
|
||||
});
|
||||
|
||||
expect(next.form.thinkingOptionId).toBe("low");
|
||||
});
|
||||
});
|
||||
|
||||
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: "" });
|
||||
|
||||
@@ -79,13 +79,6 @@ export type AgentFormAction =
|
||||
}
|
||||
| { 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;
|
||||
@@ -94,6 +87,16 @@ export type AgentFormAction =
|
||||
providerModels: AgentModelDefinition[] | null;
|
||||
providerPrefs?: ProviderPrefs | undefined;
|
||||
}
|
||||
| {
|
||||
type: "APPLY_PROFILE_FROM_USER";
|
||||
provider: AgentProvider;
|
||||
modelId: string;
|
||||
modeId: string;
|
||||
thinkingOptionId: string;
|
||||
providerDef: AgentProviderDefinition | undefined;
|
||||
providerModels: AgentModelDefinition[] | null;
|
||||
providerPrefs?: ProviderPrefs | undefined;
|
||||
}
|
||||
| { type: "SET_MODE_FROM_USER"; modeId: string }
|
||||
| {
|
||||
type: "SET_MODEL_FROM_USER";
|
||||
@@ -109,6 +112,7 @@ export type AgentFormAction =
|
||||
| { type: "RESET" };
|
||||
|
||||
type CompleteResolutionAction = Extract<AgentFormAction, { type: "COMPLETE_RESOLUTION" }>;
|
||||
type ApplyProfileAction = Extract<AgentFormAction, { type: "APPLY_PROFILE_FROM_USER" }>;
|
||||
|
||||
export function normalizeSelectedModelId(modelId: string | null | undefined): string {
|
||||
return typeof modelId === "string" ? modelId.trim() : "";
|
||||
@@ -484,20 +488,6 @@ export function resolveFormStateFromProviderModels(
|
||||
);
|
||||
}
|
||||
|
||||
function pickNextModelForProvider(input: {
|
||||
providerModels: AgentModelDefinition[] | null;
|
||||
providerPrefs: ProviderPrefs | undefined;
|
||||
}): string {
|
||||
const { providerModels, providerPrefs } = input;
|
||||
const preferredModel = normalizeSelectedModelId(providerPrefs?.model);
|
||||
const defaultModelId = resolveDefaultModelId(providerModels);
|
||||
if (preferredModel) {
|
||||
if (!providerModels) return preferredModel;
|
||||
return resolveCanonicalModelId(providerModels, preferredModel) || defaultModelId;
|
||||
}
|
||||
return defaultModelId;
|
||||
}
|
||||
|
||||
function pickNextModeForProvider(input: {
|
||||
providerDef: AgentProviderDefinition | undefined;
|
||||
providerPrefs: ProviderPrefs | undefined;
|
||||
@@ -586,6 +576,45 @@ function completeResolution(
|
||||
return { ...nextState, form: resolved };
|
||||
}
|
||||
|
||||
function applyProfile(state: AgentFormReducerState, action: ApplyProfileAction) {
|
||||
const preferredModelId = action.modelId || action.providerPrefs?.model || "";
|
||||
const normalizedModelId = resolveCanonicalModelId(action.providerModels, preferredModelId);
|
||||
const nextModelId = normalizedModelId || resolveDefaultModelId(action.providerModels);
|
||||
const availableModeIds = new Set(action.providerDef?.modes.map((mode) => mode.id) ?? []);
|
||||
const preferredModeId = action.modeId || action.providerPrefs?.mode || "";
|
||||
const defaultModeId = action.providerDef?.defaultModeId ?? "";
|
||||
let nextModeId = "";
|
||||
if (availableModeIds.has(preferredModeId)) {
|
||||
nextModeId = preferredModeId;
|
||||
} else if (availableModeIds.has(defaultModeId)) {
|
||||
nextModeId = defaultModeId;
|
||||
}
|
||||
const nextThinkingOptionId =
|
||||
action.thinkingOptionId ||
|
||||
pickNextThinkingOptionForProvider({
|
||||
providerModels: action.providerModels,
|
||||
providerPrefs: action.providerPrefs,
|
||||
modelId: nextModelId,
|
||||
});
|
||||
return {
|
||||
...state,
|
||||
form: {
|
||||
...state.form,
|
||||
provider: action.provider,
|
||||
model: nextModelId,
|
||||
modeId: nextModeId,
|
||||
thinkingOptionId: nextThinkingOptionId,
|
||||
},
|
||||
userModified: {
|
||||
...state.userModified,
|
||||
provider: true,
|
||||
model: true,
|
||||
modeId: true,
|
||||
thinkingOptionId: true,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function resolveAgentForm(
|
||||
state: AgentFormReducerState,
|
||||
action: AgentFormAction,
|
||||
@@ -611,33 +640,6 @@ export function resolveAgentForm(
|
||||
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 {
|
||||
...state,
|
||||
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 = resolveCanonicalModelId(action.providerModels, action.modelId);
|
||||
const nextModelId = normalizedModelId || resolveDefaultModelId(action.providerModels);
|
||||
@@ -669,6 +671,10 @@ export function resolveAgentForm(
|
||||
};
|
||||
}
|
||||
|
||||
case "APPLY_PROFILE_FROM_USER": {
|
||||
return applyProfile(state, action);
|
||||
}
|
||||
|
||||
case "SET_MODE_FROM_USER":
|
||||
return {
|
||||
...state,
|
||||
|
||||
Reference in New Issue
Block a user