diff --git a/src/shared/migration.test.ts b/src/shared/migration.test.ts index fd6c30a79..dc3b9c5dc 100644 --- a/src/shared/migration.test.ts +++ b/src/shared/migration.test.ts @@ -1,10 +1,14 @@ -import { describe, test, expect } from "bun:test" +import { describe, test, expect, afterEach } from "bun:test" +import * as fs from "fs" +import * as path from "path" import { AGENT_NAME_MAP, HOOK_NAME_MAP, migrateAgentNames, migrateHookNames, migrateConfigFile, + migrateAgentConfigToCategory, + shouldDeleteAgentConfig, } from "./migration" describe("migrateAgentNames", () => { @@ -19,10 +23,10 @@ describe("migrateAgentNames", () => { // #when: Migrate agent names const { migrated, changed } = migrateAgentNames(agents) - // #then: Legacy names should be migrated to Sisyphus + // #then: Legacy names should be migrated to Sisyphus/Prometheus expect(changed).toBe(true) expect(migrated["Sisyphus"]).toEqual({ temperature: 0.5 }) - expect(migrated["Planner-Sisyphus"]).toEqual({ prompt: "custom prompt" }) + expect(migrated["Prometheus (Planner)"]).toEqual({ prompt: "custom prompt" }) expect(migrated["omo"]).toBeUndefined() expect(migrated["OmO"]).toBeUndefined() expect(migrated["OmO-Plan"]).toBeUndefined() @@ -50,7 +54,7 @@ describe("migrateAgentNames", () => { // #given: Config with mixed case agent names const agents = { SISYPHUS: { model: "test" }, - "PLANNER-SISYPHUS": { prompt: "test" }, + "planner-sisyphus": { prompt: "test" }, } // #when: Migrate agent names @@ -58,7 +62,7 @@ describe("migrateAgentNames", () => { // #then: Case-insensitive lookup should migrate correctly expect(migrated["Sisyphus"]).toEqual({ model: "test" }) - expect(migrated["Planner-Sisyphus"]).toEqual({ prompt: "test" }) + expect(migrated["Prometheus (Planner)"]).toEqual({ prompt: "test" }) }) test("passes through unknown agent names unchanged", () => { @@ -220,7 +224,7 @@ describe("migrateConfigFile", () => { expect(rawConfig.omo_agent).toBeUndefined() const agents = rawConfig.agents as Record expect(agents["Sisyphus"]).toBeDefined() - expect(agents["Planner-Sisyphus"]).toBeDefined() + expect(agents["Prometheus (Planner)"]).toBeDefined() expect(rawConfig.disabled_hooks).toContain("anthropic-context-window-limit-recovery") }) }) @@ -231,13 +235,404 @@ describe("migration maps", () => { // #then: Should contain all legacy → current mappings expect(AGENT_NAME_MAP["omo"]).toBe("Sisyphus") expect(AGENT_NAME_MAP["OmO"]).toBe("Sisyphus") - expect(AGENT_NAME_MAP["OmO-Plan"]).toBe("Planner-Sisyphus") - expect(AGENT_NAME_MAP["omo-plan"]).toBe("Planner-Sisyphus") + expect(AGENT_NAME_MAP["OmO-Plan"]).toBe("Prometheus (Planner)") + expect(AGENT_NAME_MAP["omo-plan"]).toBe("Prometheus (Planner)") + expect(AGENT_NAME_MAP["Planner-Sisyphus"]).toBe("Prometheus (Planner)") + expect(AGENT_NAME_MAP["plan-consultant"]).toBe("Metis (Plan Consultant)") }) test("HOOK_NAME_MAP contains anthropic-auto-compact migration", () => { // #given/#when: Check HOOK_NAME_MAP - // #then: Should contain the legacy hook name mapping + // #then: Should contain be legacy hook name mapping expect(HOOK_NAME_MAP["anthropic-auto-compact"]).toBe("anthropic-context-window-limit-recovery") }) }) + +describe("migrateAgentConfigToCategory", () => { + test("migrates model to category when mapping exists", () => { + // #given: Config with a model that has a category mapping + const config = { + model: "google/gemini-3-pro-preview", + temperature: 0.5, + top_p: 0.9, + } + + // #when: Migrate agent config to category + const { migrated, changed } = migrateAgentConfigToCategory(config) + + // #then: Model should be replaced with category + expect(changed).toBe(true) + expect(migrated.category).toBe("visual-engineering") + expect(migrated.model).toBeUndefined() + expect(migrated.temperature).toBe(0.5) + expect(migrated.top_p).toBe(0.9) + }) + + test("does not migrate when model is not in map", () => { + // #given: Config with a model that has no mapping + const config = { + model: "custom/model", + temperature: 0.5, + } + + // #when: Migrate agent config to category + const { migrated, changed } = migrateAgentConfigToCategory(config) + + // #then: Config should remain unchanged + expect(changed).toBe(false) + expect(migrated).toEqual(config) + }) + + test("does not migrate when model is not a string", () => { + // #given: Config with non-string model + const config = { + model: { name: "test" }, + temperature: 0.5, + } + + // #when: Migrate agent config to category + const { migrated, changed } = migrateAgentConfigToCategory(config) + + // #then: Config should remain unchanged + expect(changed).toBe(false) + expect(migrated).toEqual(config) + }) + + test("handles all mapped models correctly", () => { + // #given: Configs for each mapped model + const configs = [ + { model: "google/gemini-3-pro-preview" }, + { model: "openai/gpt-5.2" }, + { model: "anthropic/claude-haiku-4-5" }, + { model: "anthropic/claude-opus-4-5" }, + { model: "anthropic/claude-sonnet-4-5" }, + ] + + const expectedCategories = ["visual-engineering", "high-iq", "quick", "most-capable", "general"] + + // #when: Migrate each config + const results = configs.map(migrateAgentConfigToCategory) + + // #then: Each model should map to correct category + results.forEach((result, index) => { + expect(result.changed).toBe(true) + expect(result.migrated.category).toBe(expectedCategories[index]) + expect(result.migrated.model).toBeUndefined() + }) + }) + + test("preserves non-model fields during migration", () => { + // #given: Config with multiple fields + const config = { + model: "openai/gpt-5.2", + temperature: 0.1, + top_p: 0.95, + maxTokens: 4096, + prompt_append: "custom instruction", + } + + // #when: Migrate agent config to category + const { migrated } = migrateAgentConfigToCategory(config) + + // #then: All non-model fields should be preserved + expect(migrated.category).toBe("high-iq") + expect(migrated.temperature).toBe(0.1) + expect(migrated.top_p).toBe(0.95) + expect(migrated.maxTokens).toBe(4096) + expect(migrated.prompt_append).toBe("custom instruction") + }) +}) + +describe("shouldDeleteAgentConfig", () => { + test("returns true when config only has category field", () => { + // #given: Config with only category field (no overrides) + const config = { category: "visual-engineering" } + + // #when: Check if config should be deleted + const shouldDelete = shouldDeleteAgentConfig(config, "visual-engineering") + + // #then: Should return true (matches category defaults) + expect(shouldDelete).toBe(true) + }) + + test("returns false when category does not exist", () => { + // #given: Config with unknown category + const config = { category: "unknown" } + + // #when: Check if config should be deleted + const shouldDelete = shouldDeleteAgentConfig(config, "unknown") + + // #then: Should return false (category not found) + expect(shouldDelete).toBe(false) + }) + + test("returns true when all fields match category defaults", () => { + // #given: Config with fields matching category defaults + const config = { + category: "visual-engineering", + model: "google/gemini-3-pro-preview", + temperature: 0.7, + } + + // #when: Check if config should be deleted + const shouldDelete = shouldDeleteAgentConfig(config, "visual-engineering") + + // #then: Should return true (all fields match defaults) + expect(shouldDelete).toBe(true) + }) + + test("returns false when fields differ from category defaults", () => { + // #given: Config with custom temperature override + const config = { + category: "visual-engineering", + temperature: 0.9, // Different from default (0.7) + } + + // #when: Check if config should be deleted + const shouldDelete = shouldDeleteAgentConfig(config, "visual-engineering") + + // #then: Should return false (has custom override) + expect(shouldDelete).toBe(false) + }) + + test("handles different categories with their defaults", () => { + // #given: Configs for different categories + const configs = [ + { category: "high-iq", temperature: 0.1 }, + { category: "quick", temperature: 0.3 }, + { category: "most-capable", temperature: 0.1 }, + { category: "general", temperature: 0.3 }, + ] + + // #when: Check each config + const results = configs.map((config) => shouldDeleteAgentConfig(config, config.category as string)) + + // #then: All should be true (all match defaults) + results.forEach((result) => { + expect(result).toBe(true) + }) + }) + + test("returns false when additional fields are present", () => { + // #given: Config with extra fields + const config = { + category: "visual-engineering", + temperature: 0.7, + custom_field: "value", // Extra field not in defaults + } + + // #when: Check if config should be deleted + const shouldDelete = shouldDeleteAgentConfig(config, "visual-engineering") + + // #then: Should return false (has extra field) + expect(shouldDelete).toBe(false) + }) + + test("handles complex config with multiple overrides", () => { + // #given: Config with multiple custom overrides + const config = { + category: "visual-engineering", + temperature: 0.5, // Different from default + top_p: 0.8, // Different from default + prompt_append: "custom prompt", // Custom field + } + + // #when: Check if config should be deleted + const shouldDelete = shouldDeleteAgentConfig(config, "visual-engineering") + + // #then: Should return false (has overrides) + expect(shouldDelete).toBe(false) + }) +}) + +describe("migrateConfigFile with backup", () => { + const cleanupPaths: string[] = [] + + afterEach(() => { + cleanupPaths.forEach((p) => { + try { + fs.unlinkSync(p) + } catch { + } + }) + }) + + test("creates backup file with timestamp when migration needed", () => { + // #given: Config file path and config needing migration + const testConfigPath = "/tmp/test-config-migration.json" + const testConfigContent = globalThis.JSON.stringify({ agents: { oracle: { model: "openai/gpt-5.2" } } }, null, 2) + const rawConfig: Record = { + agents: { + oracle: { model: "openai/gpt-5.2" }, + }, + } + + fs.writeFileSync(testConfigPath, testConfigContent) + cleanupPaths.push(testConfigPath) + + // #when: Migrate config file + const needsWrite = migrateConfigFile(testConfigPath, rawConfig) + + // #then: Backup file should be created with timestamp + expect(needsWrite).toBe(true) + + const dir = path.dirname(testConfigPath) + const basename = path.basename(testConfigPath) + const files = fs.readdirSync(dir) + const backupFiles = files.filter((f) => f.startsWith(`${basename}.bak.`)) + expect(backupFiles.length).toBeGreaterThan(0) + + const backupFile = backupFiles[0] + const backupPath = path.join(dir, backupFile) + cleanupPaths.push(backupPath) + + expect(backupFile).toMatch(/test-config-migration\.json\.bak\.\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2}/) + + const backupContent = fs.readFileSync(backupPath, "utf-8") + expect(backupContent).toBe(testConfigContent) + }) + + test("deletes agent config when all fields match category defaults", () => { + // #given: Config with agent matching category defaults + const testConfigPath = "/tmp/test-config-delete.json" + const rawConfig: Record = { + agents: { + oracle: { + model: "openai/gpt-5.2", + temperature: 0.1, + }, + }, + } + + fs.writeFileSync(testConfigPath, globalThis.JSON.stringify({ agents: { oracle: { model: "openai/gpt-5.2" } } }, null, 2)) + cleanupPaths.push(testConfigPath) + + // #when: Migrate config file + const needsWrite = migrateConfigFile(testConfigPath, rawConfig) + + // #then: Agent should be deleted (matches strategic category defaults) + expect(needsWrite).toBe(true) + + const migratedConfig = JSON.parse(fs.readFileSync(testConfigPath, "utf-8")) + expect(migratedConfig.agents).toEqual({}) + + const dir = path.dirname(testConfigPath) + const basename = path.basename(testConfigPath) + const files = fs.readdirSync(dir) + const backupFiles = files.filter((f) => f.startsWith(`${basename}.bak.`)) + backupFiles.forEach((f) => cleanupPaths.push(path.join(dir, f))) + }) + + test("keeps agent config with category when fields differ from defaults", () => { + // #given: Config with agent having custom temperature override + const testConfigPath = "/tmp/test-config-keep.json" + const rawConfig: Record = { + agents: { + oracle: { + model: "openai/gpt-5.2", + temperature: 0.5, + }, + }, + } + + fs.writeFileSync(testConfigPath, globalThis.JSON.stringify({ agents: { oracle: { model: "openai/gpt-5.2" } } }, null, 2)) + cleanupPaths.push(testConfigPath) + + // #when: Migrate config file + const needsWrite = migrateConfigFile(testConfigPath, rawConfig) + + // #then: Agent should be kept with category and custom override + expect(needsWrite).toBe(true) + + const migratedConfig = JSON.parse(fs.readFileSync(testConfigPath, "utf-8")) + const agents = migratedConfig.agents as Record + expect(agents.oracle).toBeDefined() + expect((agents.oracle as Record).category).toBe("high-iq") + expect((agents.oracle as Record).temperature).toBe(0.5) + expect((agents.oracle as Record).model).toBeUndefined() + + const dir = path.dirname(testConfigPath) + const basename = path.basename(testConfigPath) + const files = fs.readdirSync(dir) + const backupFiles = files.filter((f) => f.startsWith(`${basename}.bak.`)) + backupFiles.forEach((f) => cleanupPaths.push(path.join(dir, f))) + }) + + test("does not write when no migration needed", () => { + // #given: Config with no migrations needed + const testConfigPath = "/tmp/test-config-no-migration.json" + const rawConfig: Record = { + agents: { + Sisyphus: { model: "test" }, + }, + } + + fs.writeFileSync(testConfigPath, globalThis.JSON.stringify({ agents: { Sisyphus: { model: "test" } } }, null, 2)) + cleanupPaths.push(testConfigPath) + + // #when: Migrate config file + const needsWrite = migrateConfigFile(testConfigPath, rawConfig) + + // #then: Should not write or create backup + expect(needsWrite).toBe(false) + + const dir = path.dirname(testConfigPath) + const basename = path.basename(testConfigPath) + const files = fs.readdirSync(dir) + const backupFiles = files.filter((f) => f.startsWith(`${basename}.bak.`)) + expect(backupFiles.length).toBe(0) + }) + + test("handles multiple agent migrations correctly", () => { + // #given: Config with multiple agents needing migration + const testConfigPath = "/tmp/test-config-multi-agent.json" + const rawConfig: Record = { + agents: { + oracle: { model: "openai/gpt-5.2" }, + librarian: { model: "anthropic/claude-sonnet-4-5" }, + frontend: { + model: "google/gemini-3-pro-preview", + temperature: 0.9, + }, + }, + } + + fs.writeFileSync( + testConfigPath, + globalThis.JSON.stringify( + { + agents: { + oracle: { model: "openai/gpt-5.2" }, + librarian: { model: "anthropic/claude-sonnet-4-5" }, + frontend: { model: "google/gemini-3-pro-preview" }, + }, + }, + null, + 2, + ), + ) + cleanupPaths.push(testConfigPath) + + // #when: Migrate config file + const needsWrite = migrateConfigFile(testConfigPath, rawConfig) + + // #then: Should migrate correctly + expect(needsWrite).toBe(true) + + const migratedConfig = JSON.parse(fs.readFileSync(testConfigPath, "utf-8")) + const agents = migratedConfig.agents as Record + + expect(agents.oracle).toBeUndefined() + expect(agents.librarian).toBeUndefined() + + expect(agents.frontend).toBeDefined() + expect((agents.frontend as Record).category).toBe("visual-engineering") + expect((agents.frontend as Record).temperature).toBe(0.9) + + const dir = path.dirname(testConfigPath) + const basename = path.basename(testConfigPath) + const files = fs.readdirSync(dir) + const backupFiles = files.filter((f) => f.startsWith(`${basename}.bak.`)) + backupFiles.forEach((f) => cleanupPaths.push(path.join(dir, f))) + }) +}) diff --git a/src/shared/migration.ts b/src/shared/migration.ts index 3168293a3..36d711be7 100644 --- a/src/shared/migration.ts +++ b/src/shared/migration.ts @@ -3,14 +3,16 @@ import { log } from "./logger" // Migration map: old keys → new keys (for backward compatibility) export const AGENT_NAME_MAP: Record = { - // Legacy names (backward compatibility) omo: "Sisyphus", "OmO": "Sisyphus", - "OmO-Plan": "Planner-Sisyphus", - "omo-plan": "Planner-Sisyphus", - // Current names sisyphus: "Sisyphus", - "planner-sisyphus": "Planner-Sisyphus", + "OmO-Plan": "Prometheus (Planner)", + "omo-plan": "Prometheus (Planner)", + "Planner-Sisyphus": "Prometheus (Planner)", + "planner-sisyphus": "Prometheus (Planner)", + prometheus: "Prometheus (Planner)", + "plan-consultant": "Metis (Plan Consultant)", + metis: "Metis (Plan Consultant)", build: "build", oracle: "oracle", librarian: "librarian", @@ -26,6 +28,15 @@ export const HOOK_NAME_MAP: Record = { "anthropic-auto-compact": "anthropic-context-window-limit-recovery", } +// Model to category mapping for auto-migration +export const MODEL_TO_CATEGORY_MAP: Record = { + "google/gemini-3-pro-preview": "visual-engineering", + "openai/gpt-5.2": "high-iq", + "anthropic/claude-haiku-4-5": "quick", + "anthropic/claude-opus-4-5": "most-capable", + "anthropic/claude-sonnet-4-5": "general", +} + export function migrateAgentNames(agents: Record): { migrated: Record; changed: boolean } { const migrated: Record = {} let changed = false @@ -56,6 +67,45 @@ export function migrateHookNames(hooks: string[]): { migrated: string[]; changed return { migrated, changed } } +export function migrateAgentConfigToCategory(config: Record): { + migrated: Record + changed: boolean +} { + const { model, ...rest } = config + if (typeof model !== "string") { + return { migrated: config, changed: false } + } + + const category = MODEL_TO_CATEGORY_MAP[model] + if (!category) { + return { migrated: config, changed: false } + } + + return { + migrated: { category, ...rest }, + changed: true, + } +} + +export function shouldDeleteAgentConfig( + config: Record, + category: string +): boolean { + const { DEFAULT_CATEGORIES } = require("../tools/sisyphus-task/constants") + const defaults = DEFAULT_CATEGORIES[category] + if (!defaults) return false + + const keys = Object.keys(config).filter((k) => k !== "category") + if (keys.length === 0) return true + + for (const key of keys) { + if (config[key] !== (defaults as Record)[key]) { + return false + } + } + return true +} + export function migrateConfigFile(configPath: string, rawConfig: Record): boolean { let needsWrite = false @@ -67,6 +117,22 @@ export function migrateConfigFile(configPath: string, rawConfig: Record> + for (const [name, config] of Object.entries(agents)) { + const { migrated, changed } = migrateAgentConfigToCategory(config) + if (changed) { + const category = migrated.category as string + if (shouldDeleteAgentConfig(migrated, category)) { + delete agents[name] + } else { + agents[name] = migrated + } + needsWrite = true + } + } + } + if (rawConfig.omo_agent) { rawConfig.sisyphus_agent = rawConfig.omo_agent delete rawConfig.omo_agent @@ -83,8 +149,12 @@ export function migrateConfigFile(configPath: string, rawConfig: Record