refactor(config): extract config migration logic to testable module
- Extract AGENT_NAME_MAP, HOOK_NAME_MAP, and migration functions to src/shared/migration.ts - Add comprehensive BDD-style test suite in src/shared/migration.test.ts with 15 test cases - Export migration functions from src/shared/index.ts - Improves testability and maintainability of config migration logic Tests cover: - Agent name migrations (omo → Sisyphus, OmO-Plan → Planner-Sisyphus) - Hook name migrations (anthropic-auto-compact → anthropic-context-window-limit-recovery) - Config key migrations (omo_agent → sisyphus_agent) - Case-insensitive lookups and edge cases 🤖 GENERATED WITH ASSISTANCE OF [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode)
This commit is contained in:
@@ -15,3 +15,4 @@ export * from "./data-path"
|
||||
export * from "./config-errors"
|
||||
export * from "./claude-config-dir"
|
||||
export * from "./jsonc-parser"
|
||||
export * from "./migration"
|
||||
|
||||
243
src/shared/migration.test.ts
Normal file
243
src/shared/migration.test.ts
Normal file
@@ -0,0 +1,243 @@
|
||||
import { describe, test, expect } from "bun:test"
|
||||
import {
|
||||
AGENT_NAME_MAP,
|
||||
HOOK_NAME_MAP,
|
||||
migrateAgentNames,
|
||||
migrateHookNames,
|
||||
migrateConfigFile,
|
||||
} from "./migration"
|
||||
|
||||
describe("migrateAgentNames", () => {
|
||||
test("migrates legacy OmO names to Sisyphus", () => {
|
||||
// #given: Config with legacy OmO agent names
|
||||
const agents = {
|
||||
omo: { model: "anthropic/claude-opus-4-5" },
|
||||
OmO: { temperature: 0.5 },
|
||||
"OmO-Plan": { prompt: "custom prompt" },
|
||||
}
|
||||
|
||||
// #when: Migrate agent names
|
||||
const { migrated, changed } = migrateAgentNames(agents)
|
||||
|
||||
// #then: Legacy names should be migrated to Sisyphus
|
||||
expect(changed).toBe(true)
|
||||
expect(migrated["Sisyphus"]).toEqual({ temperature: 0.5 })
|
||||
expect(migrated["Planner-Sisyphus"]).toEqual({ prompt: "custom prompt" })
|
||||
expect(migrated["omo"]).toBeUndefined()
|
||||
expect(migrated["OmO"]).toBeUndefined()
|
||||
expect(migrated["OmO-Plan"]).toBeUndefined()
|
||||
})
|
||||
|
||||
test("preserves current agent names unchanged", () => {
|
||||
// #given: Config with current agent names
|
||||
const agents = {
|
||||
oracle: { model: "openai/gpt-5.2" },
|
||||
librarian: { model: "google/gemini-3-flash" },
|
||||
explore: { model: "opencode/grok-code" },
|
||||
}
|
||||
|
||||
// #when: Migrate agent names
|
||||
const { migrated, changed } = migrateAgentNames(agents)
|
||||
|
||||
// #then: Current names should remain unchanged
|
||||
expect(changed).toBe(false)
|
||||
expect(migrated["oracle"]).toEqual({ model: "openai/gpt-5.2" })
|
||||
expect(migrated["librarian"]).toEqual({ model: "google/gemini-3-flash" })
|
||||
expect(migrated["explore"]).toEqual({ model: "opencode/grok-code" })
|
||||
})
|
||||
|
||||
test("handles case-insensitive migration", () => {
|
||||
// #given: Config with mixed case agent names
|
||||
const agents = {
|
||||
SISYPHUS: { model: "test" },
|
||||
"PLANNER-SISYPHUS": { prompt: "test" },
|
||||
}
|
||||
|
||||
// #when: Migrate agent names
|
||||
const { migrated, changed } = migrateAgentNames(agents)
|
||||
|
||||
// #then: Case-insensitive lookup should migrate correctly
|
||||
expect(migrated["Sisyphus"]).toEqual({ model: "test" })
|
||||
expect(migrated["Planner-Sisyphus"]).toEqual({ prompt: "test" })
|
||||
})
|
||||
|
||||
test("passes through unknown agent names unchanged", () => {
|
||||
// #given: Config with unknown agent name
|
||||
const agents = {
|
||||
"custom-agent": { model: "custom/model" },
|
||||
}
|
||||
|
||||
// #when: Migrate agent names
|
||||
const { migrated, changed } = migrateAgentNames(agents)
|
||||
|
||||
// #then: Unknown names should pass through
|
||||
expect(changed).toBe(false)
|
||||
expect(migrated["custom-agent"]).toEqual({ model: "custom/model" })
|
||||
})
|
||||
})
|
||||
|
||||
describe("migrateHookNames", () => {
|
||||
test("migrates anthropic-auto-compact to anthropic-context-window-limit-recovery", () => {
|
||||
// #given: Config with legacy hook name
|
||||
const hooks = ["anthropic-auto-compact", "comment-checker"]
|
||||
|
||||
// #when: Migrate hook names
|
||||
const { migrated, changed } = migrateHookNames(hooks)
|
||||
|
||||
// #then: Legacy hook name should be migrated
|
||||
expect(changed).toBe(true)
|
||||
expect(migrated).toContain("anthropic-context-window-limit-recovery")
|
||||
expect(migrated).toContain("comment-checker")
|
||||
expect(migrated).not.toContain("anthropic-auto-compact")
|
||||
})
|
||||
|
||||
test("preserves current hook names unchanged", () => {
|
||||
// #given: Config with current hook names
|
||||
const hooks = [
|
||||
"anthropic-context-window-limit-recovery",
|
||||
"todo-continuation-enforcer",
|
||||
"session-recovery",
|
||||
]
|
||||
|
||||
// #when: Migrate hook names
|
||||
const { migrated, changed } = migrateHookNames(hooks)
|
||||
|
||||
// #then: Current names should remain unchanged
|
||||
expect(changed).toBe(false)
|
||||
expect(migrated).toEqual(hooks)
|
||||
})
|
||||
|
||||
test("handles empty hooks array", () => {
|
||||
// #given: Empty hooks array
|
||||
const hooks: string[] = []
|
||||
|
||||
// #when: Migrate hook names
|
||||
const { migrated, changed } = migrateHookNames(hooks)
|
||||
|
||||
// #then: Should return empty array with no changes
|
||||
expect(changed).toBe(false)
|
||||
expect(migrated).toEqual([])
|
||||
})
|
||||
|
||||
test("migrates multiple legacy hook names", () => {
|
||||
// #given: Multiple legacy hook names (if more are added in future)
|
||||
const hooks = ["anthropic-auto-compact"]
|
||||
|
||||
// #when: Migrate hook names
|
||||
const { migrated, changed } = migrateHookNames(hooks)
|
||||
|
||||
// #then: All legacy names should be migrated
|
||||
expect(changed).toBe(true)
|
||||
expect(migrated).toEqual(["anthropic-context-window-limit-recovery"])
|
||||
})
|
||||
})
|
||||
|
||||
describe("migrateConfigFile", () => {
|
||||
const testConfigPath = "/tmp/nonexistent-path-for-test.json"
|
||||
|
||||
test("migrates omo_agent to sisyphus_agent", () => {
|
||||
// #given: Config with legacy omo_agent key
|
||||
const rawConfig: Record<string, unknown> = {
|
||||
omo_agent: { disabled: false },
|
||||
}
|
||||
|
||||
// #when: Migrate config file
|
||||
const needsWrite = migrateConfigFile(testConfigPath, rawConfig)
|
||||
|
||||
// #then: omo_agent should be migrated to sisyphus_agent
|
||||
expect(needsWrite).toBe(true)
|
||||
expect(rawConfig.sisyphus_agent).toEqual({ disabled: false })
|
||||
expect(rawConfig.omo_agent).toBeUndefined()
|
||||
})
|
||||
|
||||
test("migrates legacy agent names in agents object", () => {
|
||||
// #given: Config with legacy agent names
|
||||
const rawConfig: Record<string, unknown> = {
|
||||
agents: {
|
||||
omo: { model: "test" },
|
||||
OmO: { temperature: 0.5 },
|
||||
},
|
||||
}
|
||||
|
||||
// #when: Migrate config file
|
||||
const needsWrite = migrateConfigFile(testConfigPath, rawConfig)
|
||||
|
||||
// #then: Agent names should be migrated
|
||||
expect(needsWrite).toBe(true)
|
||||
const agents = rawConfig.agents as Record<string, unknown>
|
||||
expect(agents["Sisyphus"]).toBeDefined()
|
||||
})
|
||||
|
||||
test("migrates legacy hook names in disabled_hooks", () => {
|
||||
// #given: Config with legacy hook names
|
||||
const rawConfig: Record<string, unknown> = {
|
||||
disabled_hooks: ["anthropic-auto-compact", "comment-checker"],
|
||||
}
|
||||
|
||||
// #when: Migrate config file
|
||||
const needsWrite = migrateConfigFile(testConfigPath, rawConfig)
|
||||
|
||||
// #then: Hook names should be migrated
|
||||
expect(needsWrite).toBe(true)
|
||||
expect(rawConfig.disabled_hooks).toContain("anthropic-context-window-limit-recovery")
|
||||
expect(rawConfig.disabled_hooks).not.toContain("anthropic-auto-compact")
|
||||
})
|
||||
|
||||
test("does not write if no migration needed", () => {
|
||||
// #given: Config with current names
|
||||
const rawConfig: Record<string, unknown> = {
|
||||
sisyphus_agent: { disabled: false },
|
||||
agents: {
|
||||
Sisyphus: { model: "test" },
|
||||
},
|
||||
disabled_hooks: ["anthropic-context-window-limit-recovery"],
|
||||
}
|
||||
|
||||
// #when: Migrate config file
|
||||
const needsWrite = migrateConfigFile(testConfigPath, rawConfig)
|
||||
|
||||
// #then: No write should be needed
|
||||
expect(needsWrite).toBe(false)
|
||||
})
|
||||
|
||||
test("handles migration of all legacy items together", () => {
|
||||
// #given: Config with all legacy items
|
||||
const rawConfig: Record<string, unknown> = {
|
||||
omo_agent: { disabled: false },
|
||||
agents: {
|
||||
omo: { model: "test" },
|
||||
"OmO-Plan": { prompt: "custom" },
|
||||
},
|
||||
disabled_hooks: ["anthropic-auto-compact"],
|
||||
}
|
||||
|
||||
// #when: Migrate config file
|
||||
const needsWrite = migrateConfigFile(testConfigPath, rawConfig)
|
||||
|
||||
// #then: All legacy items should be migrated
|
||||
expect(needsWrite).toBe(true)
|
||||
expect(rawConfig.sisyphus_agent).toEqual({ disabled: false })
|
||||
expect(rawConfig.omo_agent).toBeUndefined()
|
||||
const agents = rawConfig.agents as Record<string, unknown>
|
||||
expect(agents["Sisyphus"]).toBeDefined()
|
||||
expect(agents["Planner-Sisyphus"]).toBeDefined()
|
||||
expect(rawConfig.disabled_hooks).toContain("anthropic-context-window-limit-recovery")
|
||||
})
|
||||
})
|
||||
|
||||
describe("migration maps", () => {
|
||||
test("AGENT_NAME_MAP contains all expected legacy mappings", () => {
|
||||
// #given/#when: Check AGENT_NAME_MAP
|
||||
// #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")
|
||||
})
|
||||
|
||||
test("HOOK_NAME_MAP contains anthropic-auto-compact migration", () => {
|
||||
// #given/#when: Check HOOK_NAME_MAP
|
||||
// #then: Should contain the legacy hook name mapping
|
||||
expect(HOOK_NAME_MAP["anthropic-auto-compact"]).toBe("anthropic-context-window-limit-recovery")
|
||||
})
|
||||
})
|
||||
94
src/shared/migration.ts
Normal file
94
src/shared/migration.ts
Normal file
@@ -0,0 +1,94 @@
|
||||
import * as fs from "fs"
|
||||
import { log } from "./logger"
|
||||
|
||||
// Migration map: old keys → new keys (for backward compatibility)
|
||||
export const AGENT_NAME_MAP: Record<string, string> = {
|
||||
// Legacy names (backward compatibility)
|
||||
omo: "Sisyphus",
|
||||
"OmO": "Sisyphus",
|
||||
"OmO-Plan": "Planner-Sisyphus",
|
||||
"omo-plan": "Planner-Sisyphus",
|
||||
// Current names
|
||||
sisyphus: "Sisyphus",
|
||||
"planner-sisyphus": "Planner-Sisyphus",
|
||||
build: "build",
|
||||
oracle: "oracle",
|
||||
librarian: "librarian",
|
||||
explore: "explore",
|
||||
"frontend-ui-ux-engineer": "frontend-ui-ux-engineer",
|
||||
"document-writer": "document-writer",
|
||||
"multimodal-looker": "multimodal-looker",
|
||||
}
|
||||
|
||||
// Migration map: old hook names → new hook names (for backward compatibility)
|
||||
export const HOOK_NAME_MAP: Record<string, string> = {
|
||||
// Legacy names (backward compatibility)
|
||||
"anthropic-auto-compact": "anthropic-context-window-limit-recovery",
|
||||
}
|
||||
|
||||
export function migrateAgentNames(agents: Record<string, unknown>): { migrated: Record<string, unknown>; changed: boolean } {
|
||||
const migrated: Record<string, unknown> = {}
|
||||
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 }
|
||||
}
|
||||
|
||||
export function migrateHookNames(hooks: string[]): { migrated: string[]; changed: boolean } {
|
||||
const migrated: string[] = []
|
||||
let changed = false
|
||||
|
||||
for (const hook of hooks) {
|
||||
const newHook = HOOK_NAME_MAP[hook] ?? hook
|
||||
if (newHook !== hook) {
|
||||
changed = true
|
||||
}
|
||||
migrated.push(newHook)
|
||||
}
|
||||
|
||||
return { migrated, changed }
|
||||
}
|
||||
|
||||
export function migrateConfigFile(configPath: string, rawConfig: Record<string, unknown>): boolean {
|
||||
let needsWrite = false
|
||||
|
||||
if (rawConfig.agents && typeof rawConfig.agents === "object") {
|
||||
const { migrated, changed } = migrateAgentNames(rawConfig.agents as Record<string, unknown>)
|
||||
if (changed) {
|
||||
rawConfig.agents = migrated
|
||||
needsWrite = true
|
||||
}
|
||||
}
|
||||
|
||||
if (rawConfig.omo_agent) {
|
||||
rawConfig.sisyphus_agent = rawConfig.omo_agent
|
||||
delete rawConfig.omo_agent
|
||||
needsWrite = true
|
||||
}
|
||||
|
||||
if (rawConfig.disabled_hooks && Array.isArray(rawConfig.disabled_hooks)) {
|
||||
const { migrated, changed } = migrateHookNames(rawConfig.disabled_hooks as string[])
|
||||
if (changed) {
|
||||
rawConfig.disabled_hooks = migrated
|
||||
needsWrite = true
|
||||
}
|
||||
}
|
||||
|
||||
if (needsWrite) {
|
||||
try {
|
||||
fs.writeFileSync(configPath, JSON.stringify(rawConfig, null, 2) + "\n", "utf-8")
|
||||
log(`Migrated config file: ${configPath}`)
|
||||
} catch (err) {
|
||||
log(`Failed to write migrated config to ${configPath}:`, err)
|
||||
}
|
||||
}
|
||||
|
||||
return needsWrite
|
||||
}
|
||||
Reference in New Issue
Block a user