Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
233 changes: 129 additions & 104 deletions assistant/src/__tests__/config-loader-backfill.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,7 @@ function mergeDefaultConfigAndSeedInferenceProfiles(): void {
seedInferenceProfiles({
preserveProfileNames: defaultConfigMerge.providedLlmProfileNames,
preserveActiveProfile: defaultConfigMerge.providedLlmActiveProfile,
isHatch: defaultConfigMerge.hadOverlay,
});
}

Expand Down Expand Up @@ -309,12 +310,14 @@ describe("loadConfig startup behavior", () => {
ensureTestDir();
_setStorePath(join(WORKSPACE_DIR, "keys.enc"));
delete process.env.VELLUM_DEFAULT_WORKSPACE_CONFIG_PATH;
delete process.env.IS_PLATFORM;
invalidateConfigCache();
});

afterEach(() => {
_setStorePath(null);
delete process.env.VELLUM_DEFAULT_WORKSPACE_CONFIG_PATH;
delete process.env.IS_PLATFORM;
invalidateConfigCache();
});

Expand Down Expand Up @@ -406,7 +409,7 @@ describe("loadConfig startup behavior", () => {
expect(raw.dataDir).toBeUndefined();
});

test("hatch default overlay does not suppress first-load inference profiles", () => {
test("off-platform hatch seeds both managed and user anthropic profiles", () => {
const overlayPath = join(WORKSPACE_DIR, "hatch-overlay.json");
writeFileSync(
overlayPath,
Expand All @@ -430,27 +433,39 @@ describe("loadConfig startup behavior", () => {

expect(config.llm.default.provider).toBe("anthropic");
expect(config.llm.default.model).toBe("claude-opus-4-7");
expect(config.llm.activeProfile).toBe("balanced");
// Off-platform: user profiles are active, backed by the user's API key.
expect(config.llm.activeProfile).toBe("custom-balanced");
expect(config.llm.profiles["custom-balanced"]?.provider).toBe("anthropic");
expect(config.llm.profiles["custom-balanced"]?.provider_connection).toBe(
"anthropic-personal",
);
// Managed profiles exist as well.
expect(config.llm.profiles.balanced?.model).toBe("claude-sonnet-4-6");
expect(config.llm.profiles.balanced?.provider_connection).toBe(
"anthropic-managed",
);

const raw = JSON.parse(readFileSync(CONFIG_PATH, "utf-8"));
expect(raw.llm.default).toEqual({
provider: "anthropic",
model: "claude-opus-4-7",
});
expect(raw.llm.activeProfile).toBe("balanced");
expect(raw.llm.activeProfile).toBe("custom-balanced");
expect(raw.llm.profiles.balanced.model).toBe("claude-sonnet-4-6");
});

test("non-Anthropic hatch overlay seeds custom-* profiles and activates custom-balanced", () => {
test("on-platform hatch seeds only managed profiles", () => {
process.env.IS_PLATFORM = "true";

const overlayPath = join(WORKSPACE_DIR, "hatch-overlay.json");
writeFileSync(
overlayPath,
JSON.stringify(
{
llm: {
default: {
provider: "openai",
provider: "anthropic",
model: "claude-opus-4-7",
},
},
},
Expand All @@ -462,43 +477,26 @@ describe("loadConfig startup behavior", () => {

mergeDefaultConfigAndSeedInferenceProfiles();
const config = loadConfig();
const mainAgentConfig = resolveCallSiteConfig("mainAgent", config.llm);

expect(config.llm.activeProfile).toBe("custom-balanced");
expect(config.llm.profiles["custom-balanced"]?.provider).toBe("openai");
expect(config.llm.profiles["custom-balanced"]?.model).toBe("gpt-5.4-mini");
expect(config.llm.profiles["custom-quality-optimized"]?.provider).toBe(
"openai",
);
expect(config.llm.profiles["custom-cost-optimized"]?.provider).toBe(
"openai",
);
expect(config.llm.profiles.balanced?.provider).toBe("anthropic");
expect(config.llm.profiles["quality-optimized"]?.provider).toBe(
"anthropic",
);
expect(config.llm.profiles["cost-optimized"]?.provider).toBe("anthropic");
expect(config.llm.default.provider).toBe("openai");
expect(config.llm.default.model).toBe("gpt-5.4-mini");
expect(mainAgentConfig.provider).toBe("openai");
expect(mainAgentConfig.model).toBe("gpt-5.4-mini");

const raw = JSON.parse(readFileSync(CONFIG_PATH, "utf-8"));
expect(raw.llm.activeProfile).toBe("custom-balanced");
expect(raw.llm.default.model).toBe(
raw.llm.profiles["custom-balanced"].model,
expect(config.llm.activeProfile).toBe("balanced");
expect(config.llm.profiles.balanced?.model).toBe("claude-sonnet-4-6");
expect(config.llm.profiles.balanced?.provider_connection).toBe(
"anthropic-managed",
);
// No user profiles created on platform.
expect(config.llm.profiles["custom-balanced"]).toBeUndefined();
});

test("re-hatch from openai to anthropic resets stale custom-balanced active profile", () => {
// Pre-seed an OpenAI-style workspace: custom-balanced is active, default is
// openai. Simulates a workspace that previously hatched against OpenAI.
test("re-hatch from openai to anthropic creates user anthropic profiles off-platform", () => {
// Pre-seed an OpenAI-style workspace: user-defined custom-balanced profile
// is active, default is openai. Simulates a workspace that hatched against
// OpenAI under the pre-1.2 model.
writeConfig({
llm: {
default: { provider: "openai", model: "gpt-5.4-mini" },
profiles: {
"custom-balanced": {
source: "managed",
source: "user",
provider: "openai",
model: "gpt-5.4-mini",
},
Expand All @@ -518,41 +516,56 @@ describe("loadConfig startup behavior", () => {
mergeDefaultConfigAndSeedInferenceProfiles();

const raw = JSON.parse(readFileSync(CONFIG_PATH, "utf-8"));
expect(raw.llm.activeProfile).toBe("balanced");
expect(raw.llm.default.provider).toBe("anthropic");
expect(raw.llm.default.model).toBe("claude-sonnet-4-6");
// Off-platform re-hatch: user profiles are overwritten for the new
// provider and custom-balanced becomes active.
expect(raw.llm.activeProfile).toBe("custom-balanced");
expect(raw.llm.profiles["custom-balanced"].provider).toBe("anthropic");
expect(raw.llm.profiles["custom-balanced"].provider_connection).toBe(
"anthropic-personal",
);
// Managed profiles are also seeded for anthropic-managed.
expect(raw.llm.profiles.balanced.provider).toBe("anthropic");
expect(raw.llm.profiles.balanced.provider_connection).toBe(
"anthropic-managed",
);
});

test("unknown overlay provider falls back to Anthropic seeding", () => {
const overlayPath = join(WORKSPACE_DIR, "hatch-overlay.json");
writeFileSync(
overlayPath,
JSON.stringify(
{
llm: {
default: {
provider: "unknownprov",
},
test("on-platform re-hatch resets active profile to balanced", () => {
process.env.IS_PLATFORM = "true";

writeConfig({
llm: {
default: { provider: "openai", model: "gpt-5.4-mini" },
profiles: {
"custom-balanced": {
source: "user",
provider: "openai",
model: "gpt-5.4-mini",
},
},
null,
2,
) + "\n",
activeProfile: "custom-balanced",
},
});

const overlayPath = join(WORKSPACE_DIR, "rehatch-anthropic.json");
writeFileSync(
overlayPath,
JSON.stringify({ llm: { default: { provider: "anthropic" } } }, null, 2) +
"\n",
);
process.env.VELLUM_DEFAULT_WORKSPACE_CONFIG_PATH = overlayPath;

mergeDefaultConfigAndSeedInferenceProfiles();

const raw = JSON.parse(readFileSync(CONFIG_PATH, "utf-8"));
// On-platform: no user profiles created, active resets to managed balanced.
expect(raw.llm.activeProfile).toBe("balanced");
expect(raw.llm.profiles.balanced.provider).toBe("anthropic");
expect(raw.llm.profiles.balanced.model).toBe("claude-sonnet-4-6");
expect(raw.llm.profiles["custom-balanced"]).toBeUndefined();
expect(raw.llm.profiles["custom-quality-optimized"]).toBeUndefined();
expect(raw.llm.profiles["custom-cost-optimized"]).toBeUndefined();
// The unrecognized provider should be rewritten on disk so subsequent
// loads don't trip Zod's enum validation warning.
expect(raw.llm.default.provider).toBe("anthropic");
expect(raw.llm.profiles.balanced.provider_connection).toBe(
"anthropic-managed",
);
// The old custom-balanced is preserved on disk but no longer active.
expect(raw.llm.profiles["custom-balanced"].provider).toBe("openai");
});

test("preserves user-supplied non-catalog model on every restart (ollama custom model)", () => {
Expand All @@ -572,69 +585,79 @@ describe("loadConfig startup behavior", () => {
expect(raw.llm.default.model).toBe("codellama");
});

test("syncs llm.default.model to active profile when missing or inconsistent, respects explicit user values", () => {
// 1. Missing on disk → write to active profile's model.
const overlayMissing = join(WORKSPACE_DIR, "overlay-missing.json");
test("off-platform hatch with openai seeds user profiles and managed anthropic profiles", () => {
const overlayPath = join(WORKSPACE_DIR, "hatch-overlay.json");
writeFileSync(
overlayMissing,
JSON.stringify({ llm: { default: { provider: "openai" } } }, null, 2) +
"\n",
overlayPath,
JSON.stringify(
{ llm: { default: { provider: "openai" } } },
null,
2,
) + "\n",
);
process.env.VELLUM_DEFAULT_WORKSPACE_CONFIG_PATH = overlayMissing;
process.env.VELLUM_DEFAULT_WORKSPACE_CONFIG_PATH = overlayPath;

mergeDefaultConfigAndSeedInferenceProfiles();
let raw = JSON.parse(readFileSync(CONFIG_PATH, "utf-8"));
expect(raw.llm.default.model).toBe(
raw.llm.profiles["custom-balanced"].model,
const raw = JSON.parse(readFileSync(CONFIG_PATH, "utf-8"));

// User profiles for the hatch provider (openai).
expect(raw.llm.activeProfile).toBe("custom-balanced");
expect(raw.llm.profiles["custom-balanced"].provider).toBe("openai");
expect(raw.llm.profiles["custom-balanced"].model).toBe("gpt-5.4-mini");
expect(raw.llm.profiles["custom-balanced"].provider_connection).toBe(
"openai-personal",
);
expect(raw.llm.profiles["custom-balanced"].source).toBe("user");
expect(raw.llm.profiles["custom-quality-optimized"].provider).toBe(
"openai",
);
expect(raw.llm.profiles["custom-quality-optimized"].model).toBe("gpt-5.4");
expect(raw.llm.profiles["custom-cost-optimized"].provider).toBe("openai");
expect(raw.llm.profiles["custom-cost-optimized"].model).toBe(
"gpt-5.4-nano",
);

// Managed anthropic profiles are also seeded.
expect(raw.llm.profiles.balanced.provider).toBe("anthropic");
expect(raw.llm.profiles.balanced.provider_connection).toBe(
"anthropic-managed",
);
expect(raw.llm.profiles.balanced.source).toBe("managed");
expect(raw.llm.profiles["quality-optimized"].provider).toBe("anthropic");
expect(raw.llm.profiles["cost-optimized"].provider).toBe("anthropic");
});

// 2. Inconsistent (previous default model belongs to a different provider)
// is overwritten on the next seed run.
rmSync(CONFIG_PATH);
test("off-platform managed profiles are overwritten on every boot", () => {
// Simulate a previous boot that left managed profiles on disk.
writeConfig({
llm: {
default: { provider: "openai", model: "claude-opus-4-7" },
profiles: {
balanced: {
source: "managed",
provider: "anthropic",
model: "old-model-from-previous-release",
provider_connection: "anthropic-managed",
},
},
activeProfile: "balanced",
},
});
process.env.VELLUM_DEFAULT_WORKSPACE_CONFIG_PATH = overlayMissing;

// Non-hatch boot (no overlay). Managed profiles should be overwritten
// with the latest templates.
mergeDefaultConfigAndSeedInferenceProfiles();
raw = JSON.parse(readFileSync(CONFIG_PATH, "utf-8"));
expect(raw.llm.default.model).toBe(
raw.llm.profiles["custom-balanced"].model,
);
const raw = JSON.parse(readFileSync(CONFIG_PATH, "utf-8"));

// 3. Platform overlay supplies an explicit, internally-coherent
// profile/active/default — the user's explicit choice is preserved.
rmSync(CONFIG_PATH);
const explicit = join(WORKSPACE_DIR, "overlay-explicit.json");
writeFileSync(
explicit,
JSON.stringify(
{
llm: {
default: { provider: "openai", model: "gpt-5.4" },
profiles: {
balanced: {
source: "managed",
provider: "openai",
model: "gpt-5.4",
label: "Platform Balanced",
},
},
activeProfile: "balanced",
},
},
null,
2,
) + "\n",
expect(raw.llm.profiles.balanced.model).toBe("claude-sonnet-4-6");
expect(raw.llm.profiles.balanced.provider_connection).toBe(
"anthropic-managed",
);
process.env.VELLUM_DEFAULT_WORKSPACE_CONFIG_PATH = explicit;
mergeDefaultConfigAndSeedInferenceProfiles();
raw = JSON.parse(readFileSync(CONFIG_PATH, "utf-8"));
expect(raw.llm.activeProfile).toBe("balanced");
expect(raw.llm.default.model).toBe("gpt-5.4");
});

test("platform-provided profile fragments are not polluted by managed seeds", () => {
process.env.IS_PLATFORM = "true";

writeConfig({
llm: {
default: {
Expand Down Expand Up @@ -751,7 +774,9 @@ describe("loadConfig startup behavior", () => {
provider: "anthropic",
model: "claude-opus-4-7",
});
expect(raw.llm.activeProfile).toBe("balanced");
// Off-platform hatch: user profiles are active.
expect(raw.llm.activeProfile).toBe("custom-balanced");
expect(raw.llm.profiles["custom-balanced"].provider).toBe("anthropic");
expect(raw.llm.profiles.balanced.model).toBe("claude-sonnet-4-6");
});

Expand Down
3 changes: 3 additions & 0 deletions assistant/src/config/loader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -519,12 +519,14 @@ export function deepMergeOverwrite(
}

export type DefaultWorkspaceConfigMergeResult = {
hadOverlay: boolean;
providedLlmProfileNames: Set<string>;
providedLlmActiveProfile: boolean;
};

function emptyDefaultWorkspaceConfigMergeResult(): DefaultWorkspaceConfigMergeResult {
return {
hadOverlay: false,
providedLlmProfileNames: new Set(),
providedLlmActiveProfile: false,
};
Expand Down Expand Up @@ -570,6 +572,7 @@ export function mergeDefaultWorkspaceConfig(): DefaultWorkspaceConfigMergeResult
);
const providedProfiles = readPlainObject(llmDefaults?.profiles);
const mergeResult: DefaultWorkspaceConfigMergeResult = {
hadOverlay: true,
providedLlmProfileNames: new Set(
providedProfiles ? Object.keys(providedProfiles) : [],
),
Expand Down
Loading
Loading