feat(FN-1710): add dual-scope model settings lane fields

- Add new model settings lane fields to support dual-scope configuration (global + per-project)
- Add dual-scope provider/model override fields for executor, validator, and planning models
- Add schema entries in settings-schema.ts with validation constraints
- Add backward-compat regression tests for legacy settings shapes
- Add mixed-shape tests covering global/project scopes in same config
- Update settings-reference.md documentation with new fields
This commit is contained in:
Fusion
2026-04-15 01:57:17 -07:00
committed by gsxdsm
parent b9264670e2
commit d87b2d1de5
5 changed files with 519 additions and 9 deletions

View File

@@ -377,4 +377,128 @@ describe("GlobalSettingsStore", () => {
expect(afterInvalidate.themeMode).toBe("system");
});
});
// ── Global Lane Model Settings (FN-1710) ──────────────────────────
describe("global lane model settings", () => {
it("all *Global* lane fields default to undefined", async () => {
const settings = await store.getSettings();
expect(settings.executionGlobalProvider).toBeUndefined();
expect(settings.executionGlobalModelId).toBeUndefined();
expect(settings.planningGlobalProvider).toBeUndefined();
expect(settings.planningGlobalModelId).toBeUndefined();
expect(settings.validatorGlobalProvider).toBeUndefined();
expect(settings.validatorGlobalModelId).toBeUndefined();
expect(settings.titleSummarizerGlobalProvider).toBeUndefined();
expect(settings.titleSummarizerGlobalModelId).toBeUndefined();
});
it("persists executionGlobalProvider/executionGlobalModelId", async () => {
await store.updateSettings({
executionGlobalProvider: "anthropic",
executionGlobalModelId: "claude-sonnet-4-5",
});
const settings = await store.getSettings();
expect(settings.executionGlobalProvider).toBe("anthropic");
expect(settings.executionGlobalModelId).toBe("claude-sonnet-4-5");
// Verify persistence
const raw = JSON.parse(await readFile(join(dir, "settings.json"), "utf-8"));
expect(raw.executionGlobalProvider).toBe("anthropic");
expect(raw.executionGlobalModelId).toBe("claude-sonnet-4-5");
});
it("persists planningGlobalProvider/planningGlobalModelId", async () => {
await store.updateSettings({
planningGlobalProvider: "google",
planningGlobalModelId: "gemini-2.5-pro",
});
const settings = await store.getSettings();
expect(settings.planningGlobalProvider).toBe("google");
expect(settings.planningGlobalModelId).toBe("gemini-2.5-pro");
});
it("persists validatorGlobalProvider/validatorGlobalModelId", async () => {
await store.updateSettings({
validatorGlobalProvider: "openai",
validatorGlobalModelId: "gpt-4o",
});
const settings = await store.getSettings();
expect(settings.validatorGlobalProvider).toBe("openai");
expect(settings.validatorGlobalModelId).toBe("gpt-4o");
});
it("persists titleSummarizerGlobalProvider/titleSummarizerGlobalModelId", async () => {
await store.updateSettings({
titleSummarizerGlobalProvider: "anthropic",
titleSummarizerGlobalModelId: "claude-haiku",
});
const settings = await store.getSettings();
expect(settings.titleSummarizerGlobalProvider).toBe("anthropic");
expect(settings.titleSummarizerGlobalModelId).toBe("claude-haiku");
});
it("persists all global lane fields together", async () => {
await store.updateSettings({
executionGlobalProvider: "anthropic",
executionGlobalModelId: "claude-opus-4",
planningGlobalProvider: "google",
planningGlobalModelId: "gemini-2.5-pro",
validatorGlobalProvider: "openai",
validatorGlobalModelId: "gpt-4-turbo",
titleSummarizerGlobalProvider: "anthropic",
titleSummarizerGlobalModelId: "claude-sonnet-4-5",
});
const settings = await store.getSettings();
expect(settings.executionGlobalProvider).toBe("anthropic");
expect(settings.executionGlobalModelId).toBe("claude-opus-4");
expect(settings.planningGlobalProvider).toBe("google");
expect(settings.planningGlobalModelId).toBe("gemini-2.5-pro");
expect(settings.validatorGlobalProvider).toBe("openai");
expect(settings.validatorGlobalModelId).toBe("gpt-4-turbo");
expect(settings.titleSummarizerGlobalProvider).toBe("anthropic");
expect(settings.titleSummarizerGlobalModelId).toBe("claude-sonnet-4-5");
});
it("can clear global lane fields with null", async () => {
await store.updateSettings({
executionGlobalProvider: "anthropic",
executionGlobalModelId: "claude-sonnet-4-5",
});
// Clear them
// @ts-expect-error - null is intentionally used to clear field (null-as-delete)
await store.updateSettings({ executionGlobalProvider: null });
// @ts-expect-error - null is intentionally used to clear field (null-as-delete)
await store.updateSettings({ executionGlobalModelId: null });
const settings = await store.getSettings();
expect(settings.executionGlobalProvider).toBeUndefined();
expect(settings.executionGlobalModelId).toBeUndefined();
});
it("merges global lane fields without losing other fields", async () => {
await store.updateSettings({
executionGlobalProvider: "anthropic",
executionGlobalModelId: "claude-sonnet-4-5",
});
await store.updateSettings({
planningGlobalProvider: "google",
planningGlobalModelId: "gemini-2.5-pro",
});
const settings = await store.getSettings();
// Both should be present
expect(settings.executionGlobalProvider).toBe("anthropic");
expect(settings.executionGlobalModelId).toBe("claude-sonnet-4-5");
expect(settings.planningGlobalProvider).toBe("google");
expect(settings.planningGlobalModelId).toBe("gemini-2.5-pro");
});
});
});

View File

@@ -29,6 +29,15 @@ export const DEFAULT_GLOBAL_SETTINGS = {
favoriteModels: undefined,
openrouterModelSync: true,
modelOnboardingComplete: undefined,
// Global baseline lanes for per-role model selection
executionGlobalProvider: undefined,
executionGlobalModelId: undefined,
planningGlobalProvider: undefined,
planningGlobalModelId: undefined,
validatorGlobalProvider: undefined,
validatorGlobalModelId: undefined,
titleSummarizerGlobalProvider: undefined,
titleSummarizerGlobalModelId: undefined,
} satisfies CompleteSettings<GlobalSettings>;
/** Default values for project-level settings. */
@@ -56,6 +65,11 @@ export const DEFAULT_PROJECT_SETTINGS = {
planningModelId: undefined,
planningFallbackProvider: undefined,
planningFallbackModelId: undefined,
// Project-level default override and execution lane
defaultProviderOverride: undefined,
defaultModelIdOverride: undefined,
executionProvider: undefined,
executionModelId: undefined,
validatorProvider: undefined,
validatorModelId: undefined,
validatorFallbackProvider: undefined,

View File

@@ -705,6 +705,292 @@ describe("TaskStore", () => {
});
});
// ── Dual-Scope Lane Model Settings (FN-1710) ─────────────────────
describe("dual-scope lane model settings", () => {
// Legacy backward compatibility tests
it("legacy: project config with only planningProvider/planningModelId round-trips unchanged", async () => {
await store.updateSettings({
planningProvider: "anthropic",
planningModelId: "claude-sonnet-4-5",
});
const settings = await store.getSettings();
expect(settings.planningProvider).toBe("anthropic");
expect(settings.planningModelId).toBe("claude-sonnet-4-5");
// Verify it's persisted correctly
const configRaw = await readFile(join(rootDir, ".fusion", "config.json"), "utf-8");
const config = JSON.parse(configRaw);
expect(config.settings.planningProvider).toBe("anthropic");
expect(config.settings.planningModelId).toBe("claude-sonnet-4-5");
});
it("legacy: project config with only validatorProvider/validatorModelId round-trips unchanged", async () => {
await store.updateSettings({
validatorProvider: "openai",
validatorModelId: "gpt-4o",
});
const settings = await store.getSettings();
expect(settings.validatorProvider).toBe("openai");
expect(settings.validatorModelId).toBe("gpt-4o");
const configRaw = await readFile(join(rootDir, ".fusion", "config.json"), "utf-8");
const config = JSON.parse(configRaw);
expect(config.settings.validatorProvider).toBe("openai");
expect(config.settings.validatorModelId).toBe("gpt-4o");
});
it("legacy: project config with only titleSummarizerProvider/titleSummarizerModelId round-trips unchanged", async () => {
await store.updateSettings({
titleSummarizerProvider: "google",
titleSummarizerModelId: "gemini-2.5-pro",
});
const settings = await store.getSettings();
expect(settings.titleSummarizerProvider).toBe("google");
expect(settings.titleSummarizerModelId).toBe("gemini-2.5-pro");
const configRaw = await readFile(join(rootDir, ".fusion", "config.json"), "utf-8");
const config = JSON.parse(configRaw);
expect(config.settings.titleSummarizerProvider).toBe("google");
expect(config.settings.titleSummarizerModelId).toBe("gemini-2.5-pro");
});
it("legacy: partial provider without modelId behaves correctly", async () => {
// Set provider only without modelId (partial legacy pair)
await store.updateSettings({
planningProvider: "anthropic",
// No planningModelId
});
const settings = await store.getSettings();
expect(settings.planningProvider).toBe("anthropic");
expect(settings.planningModelId).toBeUndefined();
});
// New default override fields
it("persists defaultProviderOverride/defaultModelIdOverride via updateSettings", async () => {
await store.updateSettings({
defaultProviderOverride: "openai",
defaultModelIdOverride: "gpt-4o-mini",
});
const settings = await store.getSettings();
expect(settings.defaultProviderOverride).toBe("openai");
expect(settings.defaultModelIdOverride).toBe("gpt-4o-mini");
});
it("defaultProviderOverride/defaultModelIdOverride appear in project scope", async () => {
await store.updateSettings({
defaultProviderOverride: "anthropic",
defaultModelIdOverride: "claude-3-5-sonnet",
});
const { project } = await store.getSettingsByScope();
expect(project.defaultProviderOverride).toBe("anthropic");
expect(project.defaultModelIdOverride).toBe("claude-3-5-sonnet");
});
it("defaultProviderOverride/defaultModelIdOverride default to undefined", async () => {
const settings = await store.getSettings();
expect(settings.defaultProviderOverride).toBeUndefined();
expect(settings.defaultModelIdOverride).toBeUndefined();
});
// New execution lane fields
it("persists executionProvider/executionModelId via updateSettings", async () => {
await store.updateSettings({
executionProvider: "anthropic",
executionModelId: "claude-opus-4",
});
const settings = await store.getSettings();
expect(settings.executionProvider).toBe("anthropic");
expect(settings.executionModelId).toBe("claude-opus-4");
});
it("executionProvider/executionModelId appear in project scope", async () => {
await store.updateSettings({
executionProvider: "openai",
executionModelId: "gpt-4-turbo",
});
const { project } = await store.getSettingsByScope();
expect(project.executionProvider).toBe("openai");
expect(project.executionModelId).toBe("gpt-4-turbo");
});
it("executionProvider/executionModelId default to undefined", async () => {
const settings = await store.getSettings();
expect(settings.executionProvider).toBeUndefined();
expect(settings.executionModelId).toBeUndefined();
});
// Global lane fields via updateGlobalSettings
it("persists executionGlobalProvider/executionGlobalModelId via updateGlobalSettings", async () => {
await store.updateGlobalSettings({
executionGlobalProvider: "anthropic",
executionGlobalModelId: "claude-sonnet-4-5",
});
const settings = await store.getSettings();
expect(settings.executionGlobalProvider).toBe("anthropic");
expect(settings.executionGlobalModelId).toBe("claude-sonnet-4-5");
});
it("persists planningGlobalProvider/planningGlobalModelId via updateGlobalSettings", async () => {
await store.updateGlobalSettings({
planningGlobalProvider: "google",
planningGlobalModelId: "gemini-2.5-pro",
});
const settings = await store.getSettings();
expect(settings.planningGlobalProvider).toBe("google");
expect(settings.planningGlobalModelId).toBe("gemini-2.5-pro");
});
it("persists validatorGlobalProvider/validatorGlobalModelId via updateGlobalSettings", async () => {
await store.updateGlobalSettings({
validatorGlobalProvider: "openai",
validatorGlobalModelId: "gpt-4o",
});
const settings = await store.getSettings();
expect(settings.validatorGlobalProvider).toBe("openai");
expect(settings.validatorGlobalModelId).toBe("gpt-4o");
});
it("persists titleSummarizerGlobalProvider/titleSummarizerGlobalModelId via updateGlobalSettings", async () => {
await store.updateGlobalSettings({
titleSummarizerGlobalProvider: "anthropic",
titleSummarizerGlobalModelId: "claude-haiku",
});
const settings = await store.getSettings();
expect(settings.titleSummarizerGlobalProvider).toBe("anthropic");
expect(settings.titleSummarizerGlobalModelId).toBe("claude-haiku");
});
it("all *Global* lane fields default to undefined", async () => {
const settings = await store.getSettings();
expect(settings.executionGlobalProvider).toBeUndefined();
expect(settings.executionGlobalModelId).toBeUndefined();
expect(settings.planningGlobalProvider).toBeUndefined();
expect(settings.planningGlobalModelId).toBeUndefined();
expect(settings.validatorGlobalProvider).toBeUndefined();
expect(settings.validatorGlobalModelId).toBeUndefined();
expect(settings.titleSummarizerGlobalProvider).toBeUndefined();
expect(settings.titleSummarizerGlobalModelId).toBeUndefined();
});
// Mixed shape compatibility tests
it("mixed shape: project planningProvider + global planningGlobalProvider is stable", async () => {
// Set global baseline
await store.updateGlobalSettings({
planningGlobalProvider: "anthropic",
planningGlobalModelId: "claude-sonnet-4-5",
});
// Set project override
await store.updateSettings({
planningProvider: "openai",
planningModelId: "gpt-4o",
});
// Both should be readable with no crashes
const settings = await store.getSettings();
expect(settings.planningGlobalProvider).toBe("anthropic");
expect(settings.planningGlobalModelId).toBe("claude-sonnet-4-5");
expect(settings.planningProvider).toBe("openai");
expect(settings.planningModelId).toBe("gpt-4o");
});
it("mixed shape: project validatorProvider + global validatorGlobalProvider is stable", async () => {
await store.updateGlobalSettings({
validatorGlobalProvider: "google",
validatorGlobalModelId: "gemini-2.5-pro",
});
await store.updateSettings({
validatorProvider: "anthropic",
validatorModelId: "claude-opus-4",
});
const settings = await store.getSettings();
expect(settings.validatorGlobalProvider).toBe("google");
expect(settings.validatorGlobalModelId).toBe("gemini-2.5-pro");
expect(settings.validatorProvider).toBe("anthropic");
expect(settings.validatorModelId).toBe("claude-opus-4");
});
it("mixed shape: project titleSummarizerProvider + global titleSummarizerGlobalProvider is stable", async () => {
await store.updateGlobalSettings({
titleSummarizerGlobalProvider: "openai",
titleSummarizerGlobalModelId: "gpt-4o-mini",
});
await store.updateSettings({
titleSummarizerProvider: "anthropic",
titleSummarizerModelId: "claude-haiku",
});
const settings = await store.getSettings();
expect(settings.titleSummarizerGlobalProvider).toBe("openai");
expect(settings.titleSummarizerGlobalModelId).toBe("gpt-4o-mini");
expect(settings.titleSummarizerProvider).toBe("anthropic");
expect(settings.titleSummarizerModelId).toBe("claude-haiku");
});
// Global-only key filtering tests
it("updateSettings does not persist *Global* keys to project config", async () => {
// Attempt to set global keys through updateSettings (should be filtered)
await store.updateSettings({
executionGlobalProvider: "anthropic",
executionGlobalModelId: "claude-sonnet-4-5",
planningGlobalProvider: "openai",
planningGlobalModelId: "gpt-4o",
} as any);
// The global keys should be filtered out and not appear in merged settings
const settings = await store.getSettings();
expect(settings.executionGlobalProvider).toBeUndefined();
expect(settings.executionGlobalModelId).toBeUndefined();
expect(settings.planningGlobalProvider).toBeUndefined();
expect(settings.planningGlobalModelId).toBeUndefined();
// Verify they are NOT in the project config
const configRaw = await readFile(join(rootDir, ".fusion", "config.json"), "utf-8");
const config = JSON.parse(configRaw);
expect((config.settings as any).executionGlobalProvider).toBeUndefined();
expect((config.settings as any).planningGlobalProvider).toBeUndefined();
});
it("all global lane fields appear in global scope", async () => {
await store.updateGlobalSettings({
executionGlobalProvider: "anthropic",
executionGlobalModelId: "claude-sonnet-4-5",
planningGlobalProvider: "google",
planningGlobalModelId: "gemini-2.5-pro",
validatorGlobalProvider: "openai",
validatorGlobalModelId: "gpt-4o",
titleSummarizerGlobalProvider: "anthropic",
titleSummarizerGlobalModelId: "claude-haiku",
});
const { global } = await store.getSettingsByScope();
expect(global.executionGlobalProvider).toBe("anthropic");
expect(global.executionGlobalModelId).toBe("claude-sonnet-4-5");
expect(global.planningGlobalProvider).toBe("google");
expect(global.planningGlobalModelId).toBe("gemini-2.5-pro");
expect(global.validatorGlobalProvider).toBe("openai");
expect(global.validatorGlobalModelId).toBe("gpt-4o");
expect(global.titleSummarizerGlobalProvider).toBe("anthropic");
expect(global.titleSummarizerGlobalModelId).toBe("claude-haiku");
});
});
// ── Global/Project Settings Merging ─────────────────────────────
describe("global/project settings merging", () => {

View File

@@ -899,6 +899,38 @@ export interface GlobalSettings {
* false/undefined, the dashboard will auto-open the onboarding modal.
* Also set to true when the user explicitly dismisses onboarding. */
modelOnboardingComplete?: boolean;
/** Global baseline AI model provider for task execution (executor agent).
* This is the global lane that project-level `executionProvider` can override.
* Must be set together with `executionGlobalModelId`. Falls back to
* `defaultProvider`/`defaultModelId` when undefined. */
executionGlobalProvider?: string;
/** Global baseline AI model ID for task execution.
* Must be set together with `executionGlobalProvider`. */
executionGlobalModelId?: string;
/** Global baseline AI model provider for planning/triage (specification) agent.
* This is the global lane that project-level `planningProvider` can override.
* Must be set together with `planningGlobalModelId`. Falls back to
* `defaultProvider`/`defaultModelId` when undefined. */
planningGlobalProvider?: string;
/** Global baseline AI model ID for planning/triage.
* Must be set together with `planningGlobalProvider`. */
planningGlobalModelId?: string;
/** Global baseline AI model provider for validator/reviewer agent.
* This is the global lane that project-level `validatorProvider` can override.
* Must be set together with `validatorGlobalModelId`. Falls back to
* `defaultProvider`/`defaultModelId` when undefined. */
validatorGlobalProvider?: string;
/** Global baseline AI model ID for validator/reviewer.
* Must be set together with `validatorGlobalProvider`. */
validatorGlobalModelId?: string;
/** Global baseline AI model provider for title summarization.
* This is the global lane that project-level `titleSummarizerProvider` can override.
* Must be set together with `titleSummarizerGlobalModelId`. Falls back to
* `defaultProvider`/`defaultModelId` when undefined. */
titleSummarizerGlobalProvider?: string;
/** Global baseline AI model ID for title summarization.
* Must be set together with `titleSummarizerGlobalProvider`. */
titleSummarizerGlobalModelId?: string;
}
/**
@@ -993,6 +1025,28 @@ export interface ProjectSettings {
/** Fallback model ID for planning/triage. When unset, falls back to the
* global fallback model. Must be set together with `planningFallbackProvider`. */
planningFallbackModelId?: string;
/** Project-level override for the base default AI model provider.
* When set, this overrides the global `defaultProvider`/`defaultModelId` baseline
* for all lanes that don't have their own explicit project override.
* Must be set together with `defaultModelIdOverride`.
* NOTE: This is a schema/persistence contract only. Runtime fallback precedence
* logic is deferred to FN-1711. */
defaultProviderOverride?: string;
/** Project-level override for the base default AI model ID.
* Must be set together with `defaultProviderOverride`. */
defaultModelIdOverride?: string;
/** Project-level AI model provider for task execution (executor agent).
* This is the execution lane that overrides the global `executionGlobalProvider`.
* Must be set together with `executionModelId`. Falls back to
* `executionGlobalProvider`/`executionGlobalModelId` or
* `defaultProviderOverride`/`defaultModelIdOverride` or
* `defaultProvider`/`defaultModelId` when undefined.
* NOTE: This is a schema/persistence contract only. Runtime fallback precedence
* logic is deferred to FN-1711. */
executionProvider?: string;
/** Project-level AI model ID for task execution.
* Must be set together with `executionProvider`. */
executionModelId?: string;
/** AI model provider for validator/reviewer agent.
* Must be set together with `validatorModelId`. When both are undefined,
* falls back to `defaultProvider`/`defaultModelId`. */