diff --git a/assistant/src/__tests__/workspace-migration-082-backfill-managed-profile-labels.test.ts b/assistant/src/__tests__/workspace-migration-082-backfill-managed-profile-labels.test.ts new file mode 100644 index 00000000000..c4ec545d315 --- /dev/null +++ b/assistant/src/__tests__/workspace-migration-082-backfill-managed-profile-labels.test.ts @@ -0,0 +1,268 @@ +import { + existsSync, + mkdirSync, + readFileSync, + rmSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; + +import { backfillManagedProfileLabelsMigration } from "../workspace/migrations/082-backfill-managed-profile-labels.js"; + +let workspaceDir: string; + +function freshWorkspace(): void { + workspaceDir = join( + tmpdir(), + `vellum-migration-082-test-${Date.now()}-${Math.random().toString(36).slice(2)}`, + ); + mkdirSync(workspaceDir, { recursive: true }); +} + +function writeConfig(data: Record): void { + writeFileSync( + join(workspaceDir, "config.json"), + JSON.stringify(data, null, 2) + "\n", + ); +} + +function readConfig(): Record { + return JSON.parse(readFileSync(join(workspaceDir, "config.json"), "utf-8")); +} + +beforeEach(() => { + freshWorkspace(); +}); + +afterEach(() => { + if (existsSync(workspaceDir)) { + rmSync(workspaceDir, { recursive: true, force: true }); + } +}); + +describe("082-backfill-managed-profile-labels migration", () => { + test("has correct migration id", () => { + expect(backfillManagedProfileLabelsMigration.id).toBe( + "082-backfill-managed-profile-labels", + ); + }); + + test("backfills missing labels on the canonical managed triplet (Marina QA #5)", () => { + // The exact shape migration 052 writes — provider + model + numeric + // tuning fields, no label, no source, no provider_connection. + writeConfig({ + llm: { + default: { provider: "anthropic", model: "claude-opus-4-7" }, + profiles: { + balanced: { + provider: "anthropic", + model: "claude-sonnet-4-6", + maxTokens: 16000, + effort: "high", + thinking: { enabled: true, streamThinking: true }, + }, + "quality-optimized": { + provider: "anthropic", + model: "claude-opus-4-7", + maxTokens: 32000, + effort: "max", + thinking: { enabled: true, streamThinking: true }, + }, + "cost-optimized": { + provider: "anthropic", + model: "claude-haiku-4-5-20251001", + maxTokens: 8192, + effort: "low", + thinking: { enabled: false, streamThinking: false }, + }, + }, + activeProfile: "balanced", + }, + }); + + backfillManagedProfileLabelsMigration.run(workspaceDir); + + const config = readConfig(); + const profiles = (config.llm as Record).profiles as Record< + string, + Record + >; + expect(profiles.balanced.label).toBe("Balanced"); + expect(profiles["quality-optimized"].label).toBe("Quality"); + expect(profiles["cost-optimized"].label).toBe("Speed"); + }); + + test("preserves user-set string labels without rewriting", () => { + writeConfig({ + llm: { + profiles: { + balanced: { + provider: "anthropic", + model: "claude-sonnet-4-6", + label: "My Balanced", + }, + "quality-optimized": { + provider: "anthropic", + model: "claude-opus-4-7", + // No label — backfills. + }, + "cost-optimized": { + provider: "anthropic", + model: "claude-haiku-4-5-20251001", + label: "Speed (Managed)", + }, + }, + }, + }); + + backfillManagedProfileLabelsMigration.run(workspaceDir); + + const config = readConfig(); + const profiles = (config.llm as Record).profiles as Record< + string, + Record + >; + expect(profiles.balanced.label).toBe("My Balanced"); + expect(profiles["quality-optimized"].label).toBe("Quality"); + expect(profiles["cost-optimized"].label).toBe("Speed (Managed)"); + }); + + test("preserves explicit null labels (user cleared the label)", () => { + // `null` is a meaningful signal — the user cleared the label via the + // PUT route. Treat the key as present and skip backfill. + writeConfig({ + llm: { + profiles: { + balanced: { + provider: "anthropic", + model: "claude-sonnet-4-6", + label: null, + }, + }, + }, + }); + + backfillManagedProfileLabelsMigration.run(workspaceDir); + + const config = readConfig(); + const profiles = (config.llm as Record).profiles as Record< + string, + Record + >; + expect(profiles.balanced.label).toBeNull(); + }); + + test("does NOT touch non-canonical profile names", () => { + writeConfig({ + llm: { + profiles: { + "my-custom": { + provider: "openai", + model: "gpt-5.4", + // No label — must NOT be backfilled. + }, + balanced: { + provider: "anthropic", + model: "claude-sonnet-4-6", + // Missing label — gets backfilled. + }, + }, + }, + }); + + backfillManagedProfileLabelsMigration.run(workspaceDir); + + const config = readConfig(); + const profiles = (config.llm as Record).profiles as Record< + string, + Record + >; + expect("label" in profiles["my-custom"]).toBe(false); + expect(profiles.balanced.label).toBe("Balanced"); + }); + + test("is idempotent — second run produces no further changes", () => { + writeConfig({ + llm: { + profiles: { + balanced: { + provider: "anthropic", + model: "claude-sonnet-4-6", + }, + }, + }, + }); + + backfillManagedProfileLabelsMigration.run(workspaceDir); + const afterFirst = readFileSync( + join(workspaceDir, "config.json"), + "utf-8", + ); + + backfillManagedProfileLabelsMigration.run(workspaceDir); + const afterSecond = readFileSync( + join(workspaceDir, "config.json"), + "utf-8", + ); + + expect(afterSecond).toBe(afterFirst); + }); + + test("no-op when config.json does not exist", () => { + // Fresh workspace, no config file. Migration must not throw or create + // the file. + backfillManagedProfileLabelsMigration.run(workspaceDir); + expect(existsSync(join(workspaceDir, "config.json"))).toBe(false); + }); + + test("no-op when llm.profiles is absent", () => { + writeConfig({ llm: { default: { provider: "anthropic" } } }); + const before = readFileSync(join(workspaceDir, "config.json"), "utf-8"); + + backfillManagedProfileLabelsMigration.run(workspaceDir); + + const after = readFileSync(join(workspaceDir, "config.json"), "utf-8"); + expect(after).toBe(before); + }); + + test("ignores malformed config.json without throwing", () => { + writeFileSync(join(workspaceDir, "config.json"), "{ not valid json"); + // Should not throw. + expect(() => + backfillManagedProfileLabelsMigration.run(workspaceDir), + ).not.toThrow(); + }); + + test("does NOT skip when VELLUM_DEFAULT_WORKSPACE_CONFIG_PATH is set", () => { + // Unlike the seed migrations (040/046/052/054/...), this is a forward + // data repair that runs regardless. Platform-supplied overlay labels + // already win at the profile level (the on-disk entry has a `label` + // key, so this migration leaves it alone). Skipping the whole + // migration when the env var is set would leave migration-052 holes + // unhealed on platform-style hatches. + process.env.VELLUM_DEFAULT_WORKSPACE_CONFIG_PATH = "/tmp/overlay.json"; + writeConfig({ + llm: { + profiles: { + balanced: { + provider: "anthropic", + model: "claude-sonnet-4-6", + }, + }, + }, + }); + + try { + backfillManagedProfileLabelsMigration.run(workspaceDir); + + const config = readConfig(); + const profiles = (config.llm as Record) + .profiles as Record>; + expect(profiles.balanced.label).toBe("Balanced"); + } finally { + delete process.env.VELLUM_DEFAULT_WORKSPACE_CONFIG_PATH; + } + }); +}); diff --git a/assistant/src/config/seed-inference-profiles.ts b/assistant/src/config/seed-inference-profiles.ts index b2ede13860a..9325458fe49 100644 --- a/assistant/src/config/seed-inference-profiles.ts +++ b/assistant/src/config/seed-inference-profiles.ts @@ -173,7 +173,14 @@ export function seedInferenceProfiles( // 1. Managed profiles. Off-platform: overwrite on every boot so Vellum can // push model/config updates in new releases. On-platform: insert only if - // absent — the platform controls profiles through overlays. + // absent — the platform controls profiles through overlays, and the + // overlay fragment is authoritative even when it omits fields the local + // template carries (e.g. an overlay supplying only provider/model/label + // must not get its maxTokens/thinking polluted from the template). The + // legacy migration-052 backfill that seeds label-less Anthropic + // defaults is healed by workspace migration 082 + // (`backfill-managed-profile-labels`) rather than the seeder, so + // this skip path stays simple. // // Two user-editable fields survive the overwrite: `label` (display // rename) and `status` (active/disabled toggle). The PUT route diff --git a/assistant/src/workspace/migrations/082-backfill-managed-profile-labels.ts b/assistant/src/workspace/migrations/082-backfill-managed-profile-labels.ts new file mode 100644 index 00000000000..2abc44680ec --- /dev/null +++ b/assistant/src/workspace/migrations/082-backfill-managed-profile-labels.ts @@ -0,0 +1,154 @@ +/** + * Workspace migration `082-backfill-managed-profile-labels`. + * + * Backfills `label` on the three canonical managed inference profiles + * (`balanced`, `quality-optimized`, `cost-optimized`) when the on-disk + * profile is missing it. + * + * Why this is needed + * ------------------ + * Migration 052 (`seed-default-inference-profiles`) seeds the three + * canonical Anthropic profiles with provider/model/maxTokens/effort/ + * thinking but **no `label`** field. The runtime profile seeder + * (`seedInferenceProfiles`) materializes labels on its second pass — + * but only when the profile didn't already exist. In platform mode + * (`IS_PLATFORM=true`) it deliberately defers to the existing on-disk + * entry to avoid clobbering platform-supplied overlay fragments, so the + * label never gets written. + * + * Net result: a fresh Cloud-hosted assistant (Marina QA #5, 0.8.1) shows + * raw slugs in the profile picker — `balanced`, `quality-optimized`, + * `cost-optimized` — instead of the human labels `Balanced`, `Quality`, + * `Speed`. This migration heals existing installs by writing the bare + * template label when absent. + * + * Behavior + * -------- + * - Missing config.json -> no-op. + * - Malformed JSON -> log and no-op. + * - `llm.profiles` absent -> no-op. + * - For each canonical name: backfill `label` only when the key is + * absent on disk. An explicit `null` (user cleared the label) is + * preserved. A user-set string is preserved. + * - Non-canonical profile names are never touched. + * + * Idempotent: running twice produces no second write. + * + * Does NOT skip on `VELLUM_DEFAULT_WORKSPACE_CONFIG_PATH`: the platform + * overlay supplies its own label when it cares, and the runtime seeder's + * `preservedProfileNames` skip path will defer to that overlay-supplied + * label on every boot. This migration only fills the gap when no source + * (overlay, migration 052, or seeder) ever wrote a label. + */ + +import { existsSync, readFileSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; + +import { getLogger } from "../../util/logger.js"; +import type { WorkspaceMigration } from "./types.js"; + +const log = getLogger("workspace-migration-082-backfill-managed-profile-labels"); + +/** + * Bare template labels for the canonical managed profile triplet. Kept in + * sync with `MANAGED_PROFILE_TEMPLATES` in + * `assistant/src/config/seed-inference-profiles.ts`. Duplicated here + * intentionally — migrations are forward-only and self-contained per the + * workspace migrations AGENTS contract; future renames in the seeder + * must NOT retroactively change the data this migration writes. + */ +const CANONICAL_MANAGED_PROFILE_LABELS: Record = { + balanced: "Balanced", + "quality-optimized": "Quality", + "cost-optimized": "Speed", +}; + +export const backfillManagedProfileLabelsMigration: WorkspaceMigration = { + id: "082-backfill-managed-profile-labels", + description: + "Backfill label on canonical managed inference profiles when absent", + + run(workspaceDir: string): void { + const configPath = join(workspaceDir, "config.json"); + if (!existsSync(configPath)) { + return; + } + + let raw: string; + try { + raw = readFileSync(configPath, "utf-8"); + } catch (err) { + log.warn( + { err, path: configPath }, + "Failed to read config.json; skipping migration", + ); + return; + } + + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch (err) { + log.warn( + { err, path: configPath }, + "Failed to parse config.json; skipping migration", + ); + return; + } + + if (!isPlainObject(parsed)) { + return; + } + + const llm = readObject(parsed.llm); + if (!llm) return; + + const profiles = readObject(llm.profiles); + if (!profiles) return; + + let modified = false; + + for (const [name, label] of Object.entries( + CANONICAL_MANAGED_PROFILE_LABELS, + )) { + const profile = readObject(profiles[name]); + if (!profile) continue; + // Only backfill when the key is absent. Explicit `null` (user cleared + // the label) and any user-set string both signal intent and survive. + if ("label" in profile) continue; + profile.label = label; + modified = true; + } + + if (!modified) return; + + try { + writeFileSync(configPath, JSON.stringify(parsed, null, 2) + "\n", "utf-8"); + log.info( + { path: configPath }, + "Backfilled missing labels on canonical managed inference profiles", + ); + } catch (err) { + log.warn( + { err, path: configPath }, + "Failed to write backfilled config.json; leaving prior file in place", + ); + } + }, + + down(_workspaceDir: string): void { + // Forward-only data repair. Rolling back would re-break the picker + // for installs whose only label source was this migration. + }, +}; + +function readObject(value: unknown): Record | null { + if (value === null || typeof value !== "object" || Array.isArray(value)) { + return null; + } + return value as Record; +} + +function isPlainObject(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} diff --git a/assistant/src/workspace/migrations/registry.ts b/assistant/src/workspace/migrations/registry.ts index 68d7d30286e..1496d5b76bc 100644 --- a/assistant/src/workspace/migrations/registry.ts +++ b/assistant/src/workspace/migrations/registry.ts @@ -79,6 +79,7 @@ import { releaseNotesTavilyWebSearchMigration } from "./078-release-notes-tavily import { homeFeedNotificationOnlyMigration } from "./079-home-feed-notification-only.js"; import { restrictVercelApiTokenMetadataMigration } from "./080-restrict-vercel-api-token-metadata.js"; import { backfillBashAllowedToolsForInjectionCredentialsMigration } from "./081-backfill-bash-allowed-tools-for-injection-credentials.js"; +import { backfillManagedProfileLabelsMigration } from "./082-backfill-managed-profile-labels.js"; import { migrateToWorkspaceVolumeMigration } from "./migrate-to-workspace-volume.js"; import type { WorkspaceMigration } from "./types.js"; @@ -169,4 +170,5 @@ export const WORKSPACE_MIGRATIONS: WorkspaceMigration[] = [ homeFeedNotificationOnlyMigration, restrictVercelApiTokenMetadataMigration, backfillBashAllowedToolsForInjectionCredentialsMigration, + backfillManagedProfileLabelsMigration, ];