diff --git a/src/shared/migration.test.ts b/src/shared/migration.test.ts index f02d0f3c3..fcb01a4a8 100644 --- a/src/shared/migration.test.ts +++ b/src/shared/migration.test.ts @@ -1018,6 +1018,40 @@ describe("migrateConfigFile with backup", () => { expect(agents.oracle.category).toBe("ultrabrain") }) + test("does not write or create backups for experimental.task_system", () => { + //#given: Config with experimental.task_system enabled + const testConfigPath = "/tmp/test-config-task-system.json" + const rawConfig: Record = { + experimental: { task_system: true }, + } + + fs.writeFileSync(testConfigPath, globalThis.JSON.stringify(rawConfig, null, 2)) + cleanupPaths.push(testConfigPath) + + const dir = path.dirname(testConfigPath) + const basename = path.basename(testConfigPath) + const existingFiles = fs.readdirSync(dir) + const existingBackups = existingFiles.filter((f) => f.startsWith(`${basename}.bak.`)) + existingBackups.forEach((f) => { + const backupPath = path.join(dir, f) + try { + fs.unlinkSync(backupPath) + cleanupPaths.splice(cleanupPaths.indexOf(backupPath), 1) + } catch { + } + }) + + //#when: Migrate config file + const needsWrite = migrateConfigFile(testConfigPath, rawConfig) + + //#then: No write or backup should occur + expect(needsWrite).toBe(false) + + const files = fs.readdirSync(dir) + const backupFiles = files.filter((f) => f.startsWith(`${basename}.bak.`)) + expect(backupFiles.length).toBe(0) + }) + test("does not write when no migration needed", () => { // given: Config with no migrations needed const testConfigPath = "/tmp/test-config-no-migration.json" diff --git a/src/shared/migration.ts b/src/shared/migration.ts index ae57e24d4..969d21392 100644 --- a/src/shared/migration.ts +++ b/src/shared/migration.ts @@ -1,330 +1,5 @@ -import * as fs from "fs" -import { log } from "./logger" - -// Migration map: old keys → new keys (for backward compatibility) -export const AGENT_NAME_MAP: Record = { - // Sisyphus variants → "sisyphus" - omo: "sisyphus", - OmO: "sisyphus", - Sisyphus: "sisyphus", - sisyphus: "sisyphus", - - // Prometheus variants → "prometheus" - "OmO-Plan": "prometheus", - "omo-plan": "prometheus", - "Planner-Sisyphus": "prometheus", - "planner-sisyphus": "prometheus", - "Prometheus (Planner)": "prometheus", - prometheus: "prometheus", - - // Atlas variants → "atlas" - "orchestrator-sisyphus": "atlas", - Atlas: "atlas", - atlas: "atlas", - - // Metis variants → "metis" - "plan-consultant": "metis", - "Metis (Plan Consultant)": "metis", - metis: "metis", - - // Momus variants → "momus" - "Momus (Plan Reviewer)": "momus", - momus: "momus", - - // Sisyphus-Junior → "sisyphus-junior" - "Sisyphus-Junior": "sisyphus-junior", - "sisyphus-junior": "sisyphus-junior", - - // Already lowercase - passthrough - build: "build", - oracle: "oracle", - librarian: "librarian", - explore: "explore", - "multimodal-looker": "multimodal-looker", -} - -export const BUILTIN_AGENT_NAMES = new Set([ - "sisyphus", // was "Sisyphus" - "oracle", - "librarian", - "explore", - "multimodal-looker", - "metis", // was "Metis (Plan Consultant)" - "momus", // was "Momus (Plan Reviewer)" - "prometheus", // was "Prometheus (Planner)" - "atlas", // was "Atlas" - "build", -]) - -// Migration map: old hook names → new hook names (for backward compatibility) -// null means the hook was removed and should be filtered out from disabled_hooks -export const HOOK_NAME_MAP: Record = { - // Legacy names (backward compatibility) - "anthropic-auto-compact": "anthropic-context-window-limit-recovery", - "sisyphus-orchestrator": "atlas", - - // Removed hooks (v3.0.0) - will be filtered out and user warned - "empty-message-sanitizer": null, -} - -/** - * @deprecated LEGACY MIGRATION ONLY - * - * This map exists solely for migrating old configs that used hardcoded model strings. - * It maps legacy model strings to semantic category names, allowing users to migrate - * from explicit model configs to category-based configs. - * - * DO NOT add new entries here. New agents should use: - * - Category-based config (preferred): { category: "unspecified-high" } - * - Or inherit from OpenCode's config.model - * - * This map will be removed in a future major version once migration period ends. - */ -export const MODEL_TO_CATEGORY_MAP: Record = { - "google/gemini-3-pro": "visual-engineering", - "google/gemini-3-flash": "writing", - "openai/gpt-5.2": "ultrabrain", - "anthropic/claude-haiku-4-5": "quick", - "anthropic/claude-opus-4-6": "unspecified-high", - "anthropic/claude-sonnet-4-5": "unspecified-low", -} - -/** - * Model version migration map: old full model strings → new full model strings. - * Used to auto-upgrade hardcoded model versions in user configs when the plugin - * bumps to newer model versions. - * - * Keys are full "provider/model" strings. Only openai and anthropic entries needed. - */ -export const MODEL_VERSION_MAP: Record = { - "openai/gpt-5.2-codex": "openai/gpt-5.3-codex", - "anthropic/claude-opus-4-5": "anthropic/claude-opus-4-6", -} - -export function migrateAgentNames(agents: Record): { migrated: Record; changed: boolean } { - const migrated: Record = {} - let changed = false - - for (const [key, value] of Object.entries(agents)) { - const newKey = AGENT_NAME_MAP[key.toLowerCase()] ?? AGENT_NAME_MAP[key] ?? key - if (newKey !== key) { - changed = true - } - migrated[newKey] = value - } - - return { migrated, changed } -} - -/** - * Generate a consistent migration key for tracking applied migrations. - */ -function migrationKey(oldModel: string, newModel: string): string { - return `model-version:${oldModel}->${newModel}` -} - -export function migrateModelVersions( - configs: Record, - appliedMigrations?: Set, -): { migrated: Record; changed: boolean; newMigrations: string[] } { - const migrated: Record = {} - let changed = false - const newMigrations: string[] = [] - - for (const [key, value] of Object.entries(configs)) { - if (value && typeof value === "object" && !Array.isArray(value)) { - const config = value as Record - if (typeof config.model === "string" && MODEL_VERSION_MAP[config.model]) { - const oldModel = config.model - const newModel = MODEL_VERSION_MAP[oldModel] - const mKey = migrationKey(oldModel, newModel) - - // Skip if this migration was already applied (user may have reverted) - if (appliedMigrations?.has(mKey)) { - migrated[key] = value - continue - } - - migrated[key] = { ...config, model: newModel } - changed = true - newMigrations.push(mKey) - continue - } - } - migrated[key] = value - } - - return { migrated, changed, newMigrations } -} - -export function migrateHookNames(hooks: string[]): { migrated: string[]; changed: boolean; removed: string[] } { - const migrated: string[] = [] - const removed: string[] = [] - let changed = false - - for (const hook of hooks) { - const mapping = HOOK_NAME_MAP[hook] - - if (mapping === null) { - removed.push(hook) - changed = true - continue - } - - const newHook = mapping ?? hook - if (newHook !== hook) { - changed = true - } - migrated.push(newHook) - } - - return { migrated, changed, removed } -} - -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/delegate-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 - - // Load previously applied migrations - const existingMigrations = Array.isArray(rawConfig._migrations) - ? new Set(rawConfig._migrations as string[]) - : new Set() - const allNewMigrations: string[] = [] - - if (rawConfig.agents && typeof rawConfig.agents === "object") { - const { migrated, changed } = migrateAgentNames(rawConfig.agents as Record) - if (changed) { - rawConfig.agents = migrated - needsWrite = true - } - } - - // Migrate model versions in agents (skip already-applied migrations) - if (rawConfig.agents && typeof rawConfig.agents === "object") { - const { migrated, changed, newMigrations } = migrateModelVersions( - rawConfig.agents as Record, - existingMigrations, - ) - if (changed) { - rawConfig.agents = migrated - needsWrite = true - log(`Migrated model versions in agents config`) - } - allNewMigrations.push(...newMigrations) - } - - // Migrate model versions in categories (skip already-applied migrations) - if (rawConfig.categories && typeof rawConfig.categories === "object") { - const { migrated, changed, newMigrations } = migrateModelVersions( - rawConfig.categories as Record, - existingMigrations, - ) - if (changed) { - rawConfig.categories = migrated - needsWrite = true - log(`Migrated model versions in categories config`) - } - allNewMigrations.push(...newMigrations) - } - - // Record newly applied migrations - if (allNewMigrations.length > 0) { - const updatedMigrations = Array.from(existingMigrations) - updatedMigrations.push(...allNewMigrations) - rawConfig._migrations = updatedMigrations - needsWrite = true - } - - if (rawConfig.omo_agent) { - rawConfig.sisyphus_agent = rawConfig.omo_agent - delete rawConfig.omo_agent - needsWrite = true - } - - if (rawConfig.disabled_agents && Array.isArray(rawConfig.disabled_agents)) { - const migrated: string[] = [] - let changed = false - for (const agent of rawConfig.disabled_agents as string[]) { - const newAgent = AGENT_NAME_MAP[agent.toLowerCase()] ?? AGENT_NAME_MAP[agent] ?? agent - if (newAgent !== agent) { - changed = true - } - migrated.push(newAgent) - } - if (changed) { - rawConfig.disabled_agents = migrated - needsWrite = true - } - } - - if (rawConfig.disabled_hooks && Array.isArray(rawConfig.disabled_hooks)) { - const { migrated, changed, removed } = migrateHookNames(rawConfig.disabled_hooks as string[]) - if (changed) { - rawConfig.disabled_hooks = migrated - needsWrite = true - } - if (removed.length > 0) { - log(`Removed obsolete hooks from disabled_hooks: ${removed.join(", ")} (these hooks no longer exist in v3.0.0)`) - } - } - - if (rawConfig.experimental && typeof rawConfig.experimental === "object") { - const exp = rawConfig.experimental as Record - if ("task_system" in exp && exp.task_system !== undefined) { - needsWrite = true - } - } - - if (needsWrite) { - try { - const timestamp = new Date().toISOString().replace(/[:.]/g, "-") - const backupPath = `${configPath}.bak.${timestamp}` - fs.copyFileSync(configPath, backupPath) - - fs.writeFileSync(configPath, JSON.stringify(rawConfig, null, 2) + "\n", "utf-8") - log(`Migrated config file: ${configPath} (backup: ${backupPath})`) - } catch (err) { - log(`Failed to write migrated config to ${configPath}:`, err) - } - } - - return needsWrite -} +export { AGENT_NAME_MAP, BUILTIN_AGENT_NAMES, migrateAgentNames } from "./migration/agent-names" +export { HOOK_NAME_MAP, migrateHookNames } from "./migration/hook-names" +export { MODEL_VERSION_MAP, migrateModelVersions } from "./migration/model-versions" +export { MODEL_TO_CATEGORY_MAP, migrateAgentConfigToCategory, shouldDeleteAgentConfig } from "./migration/agent-category" +export { migrateConfigFile } from "./migration/config-migration" diff --git a/src/shared/migration/config-migration.ts b/src/shared/migration/config-migration.ts new file mode 100644 index 000000000..60cf708aa --- /dev/null +++ b/src/shared/migration/config-migration.ts @@ -0,0 +1,112 @@ +import * as fs from "fs" +import { log } from "../logger" +import { AGENT_NAME_MAP, migrateAgentNames } from "./agent-names" +import { migrateHookNames } from "./hook-names" +import { migrateModelVersions } from "./model-versions" + +export function migrateConfigFile( + configPath: string, + rawConfig: Record +): boolean { + let needsWrite = false + + // Load previously applied migrations + const existingMigrations = Array.isArray(rawConfig._migrations) + ? new Set(rawConfig._migrations as string[]) + : new Set() + const allNewMigrations: string[] = [] + + if (rawConfig.agents && typeof rawConfig.agents === "object") { + const { migrated, changed } = migrateAgentNames(rawConfig.agents as Record) + if (changed) { + rawConfig.agents = migrated + needsWrite = true + } + } + + // Migrate model versions in agents (skip already-applied migrations) + if (rawConfig.agents && typeof rawConfig.agents === "object") { + const { migrated, changed, newMigrations } = migrateModelVersions( + rawConfig.agents as Record, + existingMigrations + ) + if (changed) { + rawConfig.agents = migrated + needsWrite = true + log("Migrated model versions in agents config") + } + allNewMigrations.push(...newMigrations) + } + + // Migrate model versions in categories (skip already-applied migrations) + if (rawConfig.categories && typeof rawConfig.categories === "object") { + const { migrated, changed, newMigrations } = migrateModelVersions( + rawConfig.categories as Record, + existingMigrations + ) + if (changed) { + rawConfig.categories = migrated + needsWrite = true + log("Migrated model versions in categories config") + } + allNewMigrations.push(...newMigrations) + } + + // Record newly applied migrations + if (allNewMigrations.length > 0) { + const updatedMigrations = Array.from(existingMigrations) + updatedMigrations.push(...allNewMigrations) + rawConfig._migrations = updatedMigrations + needsWrite = true + } + + if (rawConfig.omo_agent) { + rawConfig.sisyphus_agent = rawConfig.omo_agent + delete rawConfig.omo_agent + needsWrite = true + } + + if (rawConfig.disabled_agents && Array.isArray(rawConfig.disabled_agents)) { + const migrated: string[] = [] + let changed = false + for (const agent of rawConfig.disabled_agents as string[]) { + const newAgent = AGENT_NAME_MAP[agent.toLowerCase()] ?? AGENT_NAME_MAP[agent] ?? agent + if (newAgent !== agent) { + changed = true + } + migrated.push(newAgent) + } + if (changed) { + rawConfig.disabled_agents = migrated + needsWrite = true + } + } + + if (rawConfig.disabled_hooks && Array.isArray(rawConfig.disabled_hooks)) { + const { migrated, changed, removed } = migrateHookNames(rawConfig.disabled_hooks as string[]) + if (changed) { + rawConfig.disabled_hooks = migrated + needsWrite = true + } + if (removed.length > 0) { + log( + `Removed obsolete hooks from disabled_hooks: ${removed.join(", ")} (these hooks no longer exist in v3.0.0)` + ) + } + } + + if (needsWrite) { + try { + const timestamp = new Date().toISOString().replace(/[:.]/g, "-") + const backupPath = `${configPath}.bak.${timestamp}` + fs.copyFileSync(configPath, backupPath) + + fs.writeFileSync(configPath, JSON.stringify(rawConfig, null, 2) + "\n", "utf-8") + log(`Migrated config file: ${configPath} (backup: ${backupPath})`) + } catch (err) { + log(`Failed to write migrated config to ${configPath}:`, err) + } + } + + return needsWrite +}