Persist create-agent mode preferences reliably

This commit is contained in:
Mohamed Boudra
2026-06-07 15:53:17 +07:00
parent 69715a77e9
commit d55e1622cb
9 changed files with 416 additions and 134 deletions
@@ -0,0 +1,87 @@
import { describe, expect, it } from "vitest";
import { CreateAgentPreferencesService } from "./service";
import {
mergeCreateAgentSelectionPreferences,
mergeProviderPreferences,
parseFormPreferences,
} from "./preferences";
import { FakeCreateAgentPreferenceStorage } from "./test-utils/fake-preference-storage";
describe("create agent preferences", () => {
it("keeps the selected mode after saving model and thinking", async () => {
const storage = new FakeCreateAgentPreferenceStorage();
const preferences = new CreateAgentPreferencesService(storage);
const modelWrite = preferences.update((current) =>
mergeProviderPreferences({
preferences: current,
provider: "codex",
updates: { model: "gpt-5.5", thinkingByModel: { "gpt-5.5": "high" } },
}),
);
await storage.nextWrite();
const modeWrite = preferences.update((current) =>
mergeProviderPreferences({
preferences: current,
provider: "codex",
updates: { mode: "full-access" },
}),
);
expect(storage.pendingWriteCount()).toBe(1);
storage.finishOldestWrite();
await modelWrite;
await storage.nextWrite();
storage.finishOldestWrite();
await modeWrite;
expect(storage.savedPreferences()).toEqual({
provider: "codex",
providerPreferences: {
codex: {
model: "gpt-5.5",
thinkingByModel: { "gpt-5.5": "high" },
mode: "full-access",
},
},
});
});
it("flushes the full create-agent selection into provider preferences", async () => {
const storage = new FakeCreateAgentPreferenceStorage();
const preferences = new CreateAgentPreferencesService(storage);
const saveSelection = preferences.update((current) =>
mergeCreateAgentSelectionPreferences({
preferences: current,
provider: "codex",
modelId: "gpt-5.5",
modeId: "full-access",
thinkingOptionId: "high",
featureValues: { fast_mode: true },
}),
);
await storage.nextWrite();
storage.finishOldestWrite();
await saveSelection;
expect(storage.savedPreferences()).toEqual({
provider: "codex",
providerPreferences: {
codex: {
model: "gpt-5.5",
mode: "full-access",
thinkingByModel: { "gpt-5.5": "high" },
featureValues: { fast_mode: true },
},
},
});
});
it("loads invalid stored preferences as empty preferences", () => {
expect(parseFormPreferences({ providerPreferences: { codex: { mode: 42 } } })).toEqual({});
});
});
@@ -0,0 +1,147 @@
import { z } from "zod";
import type { AgentProvider } from "@getpaseo/protocol/agent-types";
export interface FavoriteModelPreference {
provider: string;
modelId: string;
}
export interface FavoriteModelRow {
favoriteKey: string;
provider: string;
providerLabel: string;
modelId: string;
modelLabel: string;
description?: string;
}
const providerPreferencesSchema = z.object({
model: z.string().optional(),
mode: z.string().optional(),
thinkingByModel: z.record(z.string()).optional(),
featureValues: z.record(z.unknown()).optional(),
});
const formPreferencesSchema = z.object({
provider: z.string().optional(),
providerPreferences: z.record(providerPreferencesSchema).optional(),
favoriteModels: z
.array(
z.object({
provider: z.string(),
modelId: z.string(),
}),
)
.optional(),
});
export type ProviderPreferences = z.infer<typeof providerPreferencesSchema>;
export type FormPreferences = z.infer<typeof formPreferencesSchema>;
export const DEFAULT_FORM_PREFERENCES: FormPreferences = {};
export function parseFormPreferences(value: unknown): FormPreferences {
const result = formPreferencesSchema.safeParse(value);
return result.success ? result.data : DEFAULT_FORM_PREFERENCES;
}
export function mergeProviderPreferences(args: {
preferences: FormPreferences;
provider: AgentProvider;
updates: Partial<ProviderPreferences>;
}): FormPreferences {
const { preferences, provider, updates } = args;
const existingProviderPreferences = preferences.providerPreferences ?? {};
const existing = existingProviderPreferences[provider] ?? {};
const nextThinkingByModel =
updates.thinkingByModel === undefined
? existing.thinkingByModel
: {
...existing.thinkingByModel,
...updates.thinkingByModel,
};
const nextFeatureValues =
updates.featureValues === undefined
? existing.featureValues
: {
...existing.featureValues,
...updates.featureValues,
};
return {
...preferences,
provider,
providerPreferences: {
...existingProviderPreferences,
[provider]: {
...existing,
...updates,
...(nextThinkingByModel ? { thinkingByModel: nextThinkingByModel } : {}),
...(nextFeatureValues ? { featureValues: nextFeatureValues } : {}),
},
},
};
}
export function mergeCreateAgentSelectionPreferences(args: {
preferences: FormPreferences;
provider: AgentProvider | null;
modelId?: string | null;
modeId?: string | null;
thinkingOptionId?: string | null;
featureValues?: Record<string, unknown>;
}): FormPreferences {
if (!args.provider) {
return args.preferences;
}
const modelId = args.modelId?.trim() ?? "";
const modeId = args.modeId?.trim() ?? "";
const thinkingOptionId = args.thinkingOptionId?.trim() ?? "";
return mergeProviderPreferences({
preferences: args.preferences,
provider: args.provider,
updates: {
model: modelId || undefined,
mode: modeId || undefined,
...(modelId && thinkingOptionId ? { thinkingByModel: { [modelId]: thinkingOptionId } } : {}),
...(args.featureValues ? { featureValues: args.featureValues } : {}),
},
});
}
export function buildFavoriteModelKey(input: FavoriteModelPreference): string {
return `${input.provider}:${input.modelId}`;
}
export function isFavoriteModel(args: {
preferences: FormPreferences;
provider: string;
modelId: string;
}): boolean {
const favoriteKey = buildFavoriteModelKey({ provider: args.provider, modelId: args.modelId });
return (args.preferences.favoriteModels ?? []).some(
(favorite) => buildFavoriteModelKey(favorite) === favoriteKey,
);
}
export function toggleFavoriteModel(args: {
preferences: FormPreferences;
provider: string;
modelId: string;
}): FormPreferences {
const favorite = { provider: args.provider, modelId: args.modelId };
const favoriteKey = buildFavoriteModelKey(favorite);
const existingFavorites = args.preferences.favoriteModels ?? [];
const hasFavorite = existingFavorites.some(
(entry) => buildFavoriteModelKey(entry) === favoriteKey,
);
return {
...args.preferences,
favoriteModels: hasFavorite
? existingFavorites.filter((entry) => buildFavoriteModelKey(entry) !== favoriteKey)
: [...existingFavorites, favorite],
};
}
@@ -0,0 +1,62 @@
import { AsyncStorageCreateAgentPreferenceStorage } from "./storage";
import {
DEFAULT_FORM_PREFERENCES,
parseFormPreferences,
type FormPreferences,
} from "./preferences";
import type { CreateAgentPreferenceStorage } from "./storage";
export type FormPreferenceUpdate =
| Partial<FormPreferences>
| ((current: FormPreferences) => FormPreferences);
export class CreateAgentPreferencesService {
private preferences: FormPreferences = DEFAULT_FORM_PREFERENCES;
private isLoaded = false;
private loadPromise: Promise<FormPreferences> | null = null;
private writeQueue: Promise<void> = Promise.resolve();
constructor(private readonly storage: CreateAgentPreferenceStorage) {}
async load(): Promise<FormPreferences> {
if (this.isLoaded) {
return this.preferences;
}
if (!this.loadPromise) {
this.loadPromise = this.storage.read().then((stored) => {
this.preferences = parseFormPreferences(stored);
this.isLoaded = true;
return this.preferences;
});
}
return this.loadPromise;
}
async update(update: FormPreferenceUpdate): Promise<FormPreferences> {
const previousWrite = this.writeQueue;
const operation = this.applyQueuedUpdate(previousWrite, update);
this.writeQueue = operation.then(
() => undefined,
() => undefined,
);
return operation;
}
private async applyQueuedUpdate(
previousWrite: Promise<void>,
update: FormPreferenceUpdate,
): Promise<FormPreferences> {
await previousWrite;
const current = await this.load();
const next = typeof update === "function" ? update(current) : { ...current, ...update };
this.preferences = parseFormPreferences(next);
this.isLoaded = true;
await this.storage.write(this.preferences);
return this.preferences;
}
}
export const createAgentPreferencesService = new CreateAgentPreferencesService(
new AsyncStorageCreateAgentPreferenceStorage(),
);
@@ -0,0 +1,28 @@
import AsyncStorage from "@react-native-async-storage/async-storage";
import type { FormPreferences } from "./preferences";
export const CREATE_AGENT_PREFERENCES_STORAGE_KEY = "@paseo:create-agent-preferences";
export interface CreateAgentPreferenceStorage {
read(): Promise<unknown>;
write(preferences: FormPreferences): Promise<void>;
}
export class AsyncStorageCreateAgentPreferenceStorage implements CreateAgentPreferenceStorage {
async read(): Promise<unknown> {
const stored = await AsyncStorage.getItem(CREATE_AGENT_PREFERENCES_STORAGE_KEY);
if (!stored) {
return null;
}
try {
return JSON.parse(stored);
} catch {
return null;
}
}
async write(preferences: FormPreferences): Promise<void> {
await AsyncStorage.setItem(CREATE_AGENT_PREFERENCES_STORAGE_KEY, JSON.stringify(preferences));
}
}
@@ -0,0 +1,65 @@
import type { FormPreferences } from "../preferences";
import type { CreateAgentPreferenceStorage } from "../storage";
interface PendingWrite {
preferences: FormPreferences;
finish: () => void;
}
export class FakeCreateAgentPreferenceStorage implements CreateAgentPreferenceStorage {
private stored: unknown;
private readonly pendingWrites: PendingWrite[] = [];
private readonly pendingWriteWaiters: Array<(write: PendingWrite) => void> = [];
constructor(input: { stored?: unknown } = {}) {
this.stored = input.stored ?? null;
}
async read(): Promise<unknown> {
return this.stored;
}
write(preferences: FormPreferences): Promise<void> {
return new Promise((resolve) => {
const write = {
preferences,
finish: () => {
this.stored = clone(preferences);
resolve();
},
};
this.pendingWrites.push(write);
this.pendingWriteWaiters.shift()?.(write);
});
}
nextWrite(): Promise<PendingWrite> {
const next = this.pendingWrites[0];
if (next) {
return Promise.resolve(next);
}
return new Promise((resolve) => {
this.pendingWriteWaiters.push(resolve);
});
}
pendingWriteCount(): number {
return this.pendingWrites.length;
}
finishOldestWrite(): void {
const write = this.pendingWrites.shift();
if (!write) {
throw new Error("No pending create-agent preference write");
}
write.finish();
}
savedPreferences(): unknown {
return this.stored;
}
}
function clone<T>(value: T): T {
return JSON.parse(JSON.stringify(value)) as T;
}