diff --git a/apps/web/src/domains/settings/ai/ai-utils.test.ts b/apps/web/src/domains/settings/ai/ai-utils.test.ts new file mode 100644 index 00000000000..d0b5876dd47 --- /dev/null +++ b/apps/web/src/domains/settings/ai/ai-utils.test.ts @@ -0,0 +1,252 @@ +import { describe, expect, test } from "bun:test"; + +import type { DaemonConfig, DaemonConfigPatch } from "@/domains/settings/ai/ai-types"; +import { applyConfigPatch, snapshotPatchedFields } from "@/domains/settings/ai/ai-utils"; + +const BASE_CONFIG: DaemonConfig = { + services: { + "web-search": { mode: "your-own", provider: "perplexity" }, + "image-generation": { mode: "managed" }, + }, + llm: { + activeProfile: "default", + profileOrder: ["default", "fast"], + default: { provider: "anthropic", model: "claude-sonnet" }, + profiles: { + default: { provider: "anthropic", model: "claude-sonnet", status: "active" }, + fast: { provider: "openai", model: "gpt-4o-mini", status: "active" }, + }, + callSites: { + "code-review": { profile: "default", provider: "anthropic", model: "claude-sonnet" }, + }, + }, +}; + +describe("applyConfigPatch", () => { + test("merges service fields without clobbering siblings", () => { + const patch: DaemonConfigPatch = { + services: { "web-search": { mode: "managed" } }, + }; + const result = applyConfigPatch(BASE_CONFIG, patch); + expect(result.services?.["web-search"]).toEqual({ + mode: "managed", + provider: "perplexity", + }); + expect(result.services?.["image-generation"]).toEqual({ mode: "managed" }); + }); + + test("null service entry deletes it", () => { + const patch: DaemonConfigPatch = { + services: { "image-generation": null }, + }; + const result = applyConfigPatch(BASE_CONFIG, patch); + expect(result.services?.["image-generation"]).toBeUndefined(); + expect(result.services?.["web-search"]).toEqual( + BASE_CONFIG.services?.["web-search"], + ); + }); + + test("updates activeProfile", () => { + const patch: DaemonConfigPatch = { llm: { activeProfile: "fast" } }; + const result = applyConfigPatch(BASE_CONFIG, patch); + expect(result.llm?.activeProfile).toBe("fast"); + expect(result.llm?.profileOrder).toEqual(["default", "fast"]); + }); + + test("replaces profileOrder", () => { + const patch: DaemonConfigPatch = { + llm: { profileOrder: ["fast", "default"] }, + }; + const result = applyConfigPatch(BASE_CONFIG, patch); + expect(result.llm?.profileOrder).toEqual(["fast", "default"]); + }); + + test("null default deletes it", () => { + const patch: DaemonConfigPatch = { llm: { default: null } }; + const result = applyConfigPatch(BASE_CONFIG, patch); + expect(result.llm?.default).toBeUndefined(); + }); + + test("merges default fields", () => { + const patch: DaemonConfigPatch = { + llm: { default: { model: "claude-opus" } }, + }; + const result = applyConfigPatch(BASE_CONFIG, patch); + expect(result.llm?.default).toEqual({ + provider: "anthropic", + model: "claude-opus", + }); + }); + + test("merges profile entry fields without clobbering siblings", () => { + const patch: DaemonConfigPatch = { + llm: { profiles: { default: { model: "claude-opus" } } }, + }; + const result = applyConfigPatch(BASE_CONFIG, patch); + expect(result.llm?.profiles?.default).toEqual({ + provider: "anthropic", + model: "claude-opus", + status: "active", + }); + expect(result.llm?.profiles?.fast).toEqual( + BASE_CONFIG.llm?.profiles?.fast, + ); + }); + + test("null profile entry deletes it", () => { + const patch: DaemonConfigPatch = { + llm: { profiles: { fast: null } }, + }; + const result = applyConfigPatch(BASE_CONFIG, patch); + expect(result.llm?.profiles?.fast).toBeUndefined(); + expect(result.llm?.profiles?.default).toEqual( + BASE_CONFIG.llm?.profiles?.default, + ); + }); + + test("adds new profile entry", () => { + const patch: DaemonConfigPatch = { + llm: { + profiles: { creative: { provider: "openai", model: "gpt-4o" } }, + }, + }; + const result = applyConfigPatch(BASE_CONFIG, patch); + expect(result.llm?.profiles?.creative).toEqual({ + provider: "openai", + model: "gpt-4o", + }); + expect(result.llm?.profiles?.default).toEqual( + BASE_CONFIG.llm?.profiles?.default, + ); + }); + + test("null callSite sets value to null (reset override)", () => { + const patch: DaemonConfigPatch = { + llm: { callSites: { "code-review": null } }, + }; + const result = applyConfigPatch(BASE_CONFIG, patch); + expect(result.llm?.callSites?.["code-review"]).toBeNull(); + }); + + test("merges callSite fields", () => { + const patch: DaemonConfigPatch = { + llm: { callSites: { "code-review": { model: "claude-opus" } } }, + }; + const result = applyConfigPatch(BASE_CONFIG, patch); + expect(result.llm?.callSites?.["code-review"]).toEqual({ + profile: "default", + provider: "anthropic", + model: "claude-opus", + }); + }); + + test("adds new callSite override", () => { + const patch: DaemonConfigPatch = { + llm: { + callSites: { "web-browse": { profile: "fast" } }, + }, + }; + const result = applyConfigPatch(BASE_CONFIG, patch); + expect(result.llm?.callSites?.["web-browse"]).toEqual({ + profile: "fast", + }); + expect(result.llm?.callSites?.["code-review"]).toEqual( + BASE_CONFIG.llm?.callSites?.["code-review"], + ); + }); + + test("empty patch returns a shallow copy", () => { + const result = applyConfigPatch(BASE_CONFIG, {}); + expect(result).toEqual(BASE_CONFIG); + expect(result).not.toBe(BASE_CONFIG); + }); + + test("handles config with missing optional fields", () => { + const sparse: DaemonConfig = {}; + const patch: DaemonConfigPatch = { + services: { "web-search": { mode: "managed" } }, + llm: { activeProfile: "default" }, + }; + const result = applyConfigPatch(sparse, patch); + expect(result.services?.["web-search"]).toEqual({ mode: "managed" }); + expect(result.llm?.activeProfile).toBe("default"); + }); + + test("does not mutate the original config", () => { + const original = structuredClone(BASE_CONFIG); + applyConfigPatch(BASE_CONFIG, { + services: { "web-search": { mode: "managed" } }, + llm: { profiles: { default: { model: "claude-opus" } } }, + }); + expect(BASE_CONFIG).toEqual(original); + }); +}); + +describe("snapshotPatchedFields", () => { + test("snapshots only service keys touched by the patch", () => { + const patch: DaemonConfigPatch = { + services: { "web-search": { mode: "managed" } }, + }; + const snapshot = snapshotPatchedFields(BASE_CONFIG, patch); + expect(snapshot.services?.["web-search"]).toEqual({ + mode: "your-own", + provider: "perplexity", + }); + expect(snapshot.services?.["image-generation"]).toBeUndefined(); + expect(snapshot.llm).toBeUndefined(); + }); + + test("snapshots null for missing service entries", () => { + const sparse: DaemonConfig = {}; + const patch: DaemonConfigPatch = { + services: { "web-search": { mode: "managed" } }, + }; + const snapshot = snapshotPatchedFields(sparse, patch); + expect(snapshot.services?.["web-search"]).toBeNull(); + }); + + test("snapshots only the profile entries touched by the patch", () => { + const patch: DaemonConfigPatch = { + llm: { profiles: { default: { model: "claude-opus" } } }, + }; + const snapshot = snapshotPatchedFields(BASE_CONFIG, patch); + expect(snapshot.llm?.profiles?.["default"]).toEqual( + BASE_CONFIG.llm?.profiles?.["default"], + ); + expect(snapshot.llm?.profiles?.["fast"]).toBeUndefined(); + }); + + test("snapshots activeProfile and default independently", () => { + const patch: DaemonConfigPatch = { + llm: { activeProfile: "fast" }, + }; + const snapshot = snapshotPatchedFields(BASE_CONFIG, patch); + expect(snapshot.llm?.activeProfile).toBe("default"); + expect(snapshot.llm?.default).toBeUndefined(); + expect(snapshot.llm?.profiles).toBeUndefined(); + }); + + test("field-level rollback preserves concurrent mutation", () => { + // Simulate: mutation A changes web-search, mutation B changes image-generation. + // If A fails, rolling back A's snapshot should NOT revert B's change. + const afterB = applyConfigPatch(BASE_CONFIG, { + services: { "image-generation": { mode: "your-own" } }, + }); + const afterAandB = applyConfigPatch(afterB, { + services: { "web-search": { mode: "managed" } }, + }); + // Snapshot taken BEFORE A's optimistic update (at afterB) + const snapshotA = snapshotPatchedFields(afterB, { + services: { "web-search": { mode: "managed" } }, + }); + // A fails — roll back only A's fields + const rolledBack = applyConfigPatch(afterAandB, snapshotA); + // B's change should survive + expect(rolledBack.services?.["image-generation"]).toEqual({ mode: "your-own" }); + // A's change should be reverted + expect(rolledBack.services?.["web-search"]).toEqual({ + mode: "your-own", + provider: "perplexity", + }); + }); +}); diff --git a/apps/web/src/domains/settings/ai/ai-utils.ts b/apps/web/src/domains/settings/ai/ai-utils.ts index 69aa1bd21a8..c7657e68b01 100644 --- a/apps/web/src/domains/settings/ai/ai-utils.ts +++ b/apps/web/src/domains/settings/ai/ai-utils.ts @@ -4,7 +4,9 @@ import { } from "@/assistant/generated/web-search-provider-catalog.gen"; import type { + CallSiteOverrideDraft, DaemonConfig, + DaemonConfigPatch, DaemonConfigReconciliation, InferenceTokenBudgetState, ProfileEntry, @@ -140,3 +142,149 @@ export function getLongContextPricingHint( export function getWebSearchProviderKeyStorage(provider: string): string { return WEB_SEARCH_PROVIDER_KEY_STORAGE[provider] ?? ""; } + +/** + * Snapshots only the fields in `config` that `patch` will touch, so + * `onError` can roll back just those fields without clobbering concurrent + * mutations' optimistic updates. + * + * Returns a `DaemonConfigPatch`-shaped object containing the previous values + * for every key present in the patch. Feed it back to `applyConfigPatch` in + * the rollback updater to restore exactly what was changed. + */ +export function snapshotPatchedFields(config: DaemonConfig, patch: DaemonConfigPatch): DaemonConfigPatch { + const snapshot: DaemonConfigPatch = {}; + + if (patch.services) { + const services: NonNullable = {}; + if ("web-search" in patch.services) { + services["web-search"] = config.services?.["web-search"] + ? { ...config.services["web-search"] } + : null; + } + if ("image-generation" in patch.services) { + services["image-generation"] = config.services?.["image-generation"] + ? { ...config.services["image-generation"] } + : null; + } + snapshot.services = services; + } + + if (patch.llm) { + const llm: NonNullable = {}; + + if ("activeProfile" in patch.llm) { + llm.activeProfile = config.llm?.activeProfile ?? null; + } + if ("profileOrder" in patch.llm) { + llm.profileOrder = config.llm?.profileOrder ? [...config.llm.profileOrder] : []; + } + if ("default" in patch.llm) { + llm.default = config.llm?.default ? { ...config.llm.default } : null; + } + + if (patch.llm.profiles) { + const profiles: Record | null> = {}; + for (const name of Object.keys(patch.llm.profiles)) { + const existing = config.llm?.profiles?.[name]; + profiles[name] = existing ? { ...existing } : null; + } + llm.profiles = profiles; + } + + if (patch.llm.callSites) { + const callSites: Record = {}; + for (const id of Object.keys(patch.llm.callSites)) { + const existing = config.llm?.callSites?.[id]; + callSites[id] = existing ? { ...existing } : null; + } + llm.callSites = callSites; + } + + snapshot.llm = llm; + } + + return snapshot; +} + +/** + * Applies a `DaemonConfigPatch` to a cached `DaemonConfig`, mimicking the + * daemon's deep-merge semantics: omitted keys are left unchanged, explicit + * `null` at record-entry positions deletes the entry. + * + * Used by the mutation hook's `onMutate` callback to optimistically update + * the TanStack Query cache before the server responds, and by `onError` to + * roll back only the fields that were changed (via a snapshot from + * `snapshotPatchedFields`). + */ +export function applyConfigPatch(config: DaemonConfig, patch: DaemonConfigPatch): DaemonConfig { + const result: DaemonConfig = { ...config }; + + if (patch.services) { + const services: NonNullable = { ...result.services }; + if ("web-search" in patch.services) { + const ws = patch.services["web-search"]; + if (ws === null) { + delete services["web-search"]; + } else if (ws) { + services["web-search"] = { ...services["web-search"], ...ws }; + } + } + if ("image-generation" in patch.services) { + const ig = patch.services["image-generation"]; + if (ig === null) { + delete services["image-generation"]; + } else if (ig) { + services["image-generation"] = { ...services["image-generation"], ...ig }; + } + } + result.services = services; + } + + if (patch.llm) { + const llm: NonNullable = { ...result.llm }; + + if ("activeProfile" in patch.llm) { + llm.activeProfile = patch.llm.activeProfile ?? undefined; + } + if ("profileOrder" in patch.llm) { + llm.profileOrder = patch.llm.profileOrder; + } + if ("default" in patch.llm) { + if (patch.llm.default === null) { + delete llm.default; + } else if (patch.llm.default) { + llm.default = { ...llm.default, ...patch.llm.default }; + } + } + + if (patch.llm.profiles) { + const profiles: Record = { ...llm.profiles }; + for (const [name, entry] of Object.entries(patch.llm.profiles)) { + if (entry === null) { + delete profiles[name]; + } else { + profiles[name] = { ...profiles[name], ...entry }; + } + } + llm.profiles = profiles; + } + + if (patch.llm.callSites) { + const callSites: NonNullable["callSites"] = { ...llm.callSites }; + for (const [id, entry] of Object.entries(patch.llm.callSites)) { + if (entry === null) { + callSites[id] = null; + } else { + const existing = callSites[id]; + callSites[id] = { ...(existing ?? {}), ...entry }; + } + } + llm.callSites = callSites; + } + + result.llm = llm; + } + + return result; +} diff --git a/apps/web/src/domains/settings/ai/use-daemon-config.ts b/apps/web/src/domains/settings/ai/use-daemon-config.ts index 7fec4a6bfee..27dfda86695 100644 --- a/apps/web/src/domains/settings/ai/use-daemon-config.ts +++ b/apps/web/src/domains/settings/ai/use-daemon-config.ts @@ -23,7 +23,7 @@ import { import { configGet, configPatch, secretsPost } from "@/generated/daemon/sdk.gen"; import { captureError } from "@/lib/sentry/capture-error"; import { assistantDaemonConfigQueryKey } from "@/lib/sync/query-tags"; -import { assertProvisionSuccess, buildOrderedProfiles } from "@/domains/settings/ai/ai-utils"; +import { applyConfigPatch, assertProvisionSuccess, buildOrderedProfiles, snapshotPatchedFields } from "@/domains/settings/ai/ai-utils"; import type { CallSiteOverrideDraft, DaemonConfig, DaemonConfigPatch, ProfileEntry } from "@/domains/settings/ai/ai-types"; // --------------------------------------------------------------------------- @@ -125,9 +125,17 @@ export function useDaemonConfigQuery() { /** * Mutation hook for daemon config patches. * - * Wraps `configPatch` in a `useMutation` with automatic cache invalidation - * on settle. Resolves the assistant ID lazily via `useAssistantId` so - * callers don't need to gate on the assistant list being loaded. + * Wraps `configPatch` with optimistic cache updates and auto-invalidation: + * - `onMutate` applies the patch to the query cache immediately via + * `applyConfigPatch`, so consumers see the new values before the server + * responds. This prevents derived state (e.g. `configChanged`) from + * briefly reverting to stale values during the refetch window. + * - `onError` rolls the cache back to the pre-mutation snapshot. + * - `onSettled` invalidates the cache so a refetch replaces the optimistic + * data with the server's authoritative response. + * + * Resolves the assistant ID lazily via `useAssistantId` so callers don't + * need to gate on the assistant list being loaded. */ export function useDaemonConfigMutation() { const queryClient = useQueryClient(); @@ -143,6 +151,25 @@ export function useDaemonConfigMutation() { }); return { data, resolvedId }; }, + onMutate: async (body) => { + if (!assistantId) return; + const queryKey = assistantDaemonConfigQueryKey(assistantId); + await queryClient.cancelQueries({ queryKey }); + const current = queryClient.getQueryData(queryKey); + const rollback = current ? snapshotPatchedFields(current, body) : undefined; + queryClient.setQueryData(queryKey, (old) => + old ? applyConfigPatch(old, body) : old, + ); + return { rollback, queryKey }; + }, + onError: (_err, _body, context) => { + const { queryKey, rollback } = context ?? {}; + if (queryKey && rollback) { + queryClient.setQueryData(queryKey, (old) => + old ? applyConfigPatch(old, rollback) : old, + ); + } + }, onSettled: (result) => { const idToInvalidate = result?.resolvedId ?? assistantId; void queryClient.invalidateQueries({