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:
Mohamed Boudra
2026-08-13 18:48:02 +02:00
committed by GitHub
parent 90f3d95415
commit 9c195feb9b
17 changed files with 622 additions and 193 deletions
+1
View File
@@ -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);