Persist create-agent mode preferences reliably
This commit is contained in:
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user