Skip to content

Commit ffeb92e

Browse files
committed
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)
1 parent d49c221 commit ffeb92e

File tree

3 files changed

+338
-0
lines changed

3 files changed

+338
-0
lines changed

src/shared/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,3 +15,4 @@ export * from "./data-path"
1515
export * from "./config-errors"
1616
export * from "./claude-config-dir"
1717
export * from "./jsonc-parser"
18+
export * from "./migration"

src/shared/migration.test.ts

Lines changed: 243 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,243 @@
1+
import { describe, test, expect } from "bun:test"
2+
import {
3+
AGENT_NAME_MAP,
4+
HOOK_NAME_MAP,
5+
migrateAgentNames,
6+
migrateHookNames,
7+
migrateConfigFile,
8+
} from "./migration"
9+
10+
describe("migrateAgentNames", () => {
11+
test("migrates legacy OmO names to Sisyphus", () => {
12+
// #given: Config with legacy OmO agent names
13+
const agents = {
14+
omo: { model: "anthropic/claude-opus-4-5" },
15+
OmO: { temperature: 0.5 },
16+
"OmO-Plan": { prompt: "custom prompt" },
17+
}
18+
19+
// #when: Migrate agent names
20+
const { migrated, changed } = migrateAgentNames(agents)
21+
22+
// #then: Legacy names should be migrated to Sisyphus
23+
expect(changed).toBe(true)
24+
expect(migrated["Sisyphus"]).toEqual({ temperature: 0.5 })
25+
expect(migrated["Planner-Sisyphus"]).toEqual({ prompt: "custom prompt" })
26+
expect(migrated["omo"]).toBeUndefined()
27+
expect(migrated["OmO"]).toBeUndefined()
28+
expect(migrated["OmO-Plan"]).toBeUndefined()
29+
})
30+
31+
test("preserves current agent names unchanged", () => {
32+
// #given: Config with current agent names
33+
const agents = {
34+
oracle: { model: "openai/gpt-5.2" },
35+
librarian: { model: "google/gemini-3-flash" },
36+
explore: { model: "opencode/grok-code" },
37+
}
38+
39+
// #when: Migrate agent names
40+
const { migrated, changed } = migrateAgentNames(agents)
41+
42+
// #then: Current names should remain unchanged
43+
expect(changed).toBe(false)
44+
expect(migrated["oracle"]).toEqual({ model: "openai/gpt-5.2" })
45+
expect(migrated["librarian"]).toEqual({ model: "google/gemini-3-flash" })
46+
expect(migrated["explore"]).toEqual({ model: "opencode/grok-code" })
47+
})
48+
49+
test("handles case-insensitive migration", () => {
50+
// #given: Config with mixed case agent names
51+
const agents = {
52+
SISYPHUS: { model: "test" },
53+
"PLANNER-SISYPHUS": { prompt: "test" },
54+
}
55+
56+
// #when: Migrate agent names
57+
const { migrated, changed } = migrateAgentNames(agents)
58+
59+
// #then: Case-insensitive lookup should migrate correctly
60+
expect(migrated["Sisyphus"]).toEqual({ model: "test" })
61+
expect(migrated["Planner-Sisyphus"]).toEqual({ prompt: "test" })
62+
})
63+
64+
test("passes through unknown agent names unchanged", () => {
65+
// #given: Config with unknown agent name
66+
const agents = {
67+
"custom-agent": { model: "custom/model" },
68+
}
69+
70+
// #when: Migrate agent names
71+
const { migrated, changed } = migrateAgentNames(agents)
72+
73+
// #then: Unknown names should pass through
74+
expect(changed).toBe(false)
75+
expect(migrated["custom-agent"]).toEqual({ model: "custom/model" })
76+
})
77+
})
78+
79+
describe("migrateHookNames", () => {
80+
test("migrates anthropic-auto-compact to anthropic-context-window-limit-recovery", () => {
81+
// #given: Config with legacy hook name
82+
const hooks = ["anthropic-auto-compact", "comment-checker"]
83+
84+
// #when: Migrate hook names
85+
const { migrated, changed } = migrateHookNames(hooks)
86+
87+
// #then: Legacy hook name should be migrated
88+
expect(changed).toBe(true)
89+
expect(migrated).toContain("anthropic-context-window-limit-recovery")
90+
expect(migrated).toContain("comment-checker")
91+
expect(migrated).not.toContain("anthropic-auto-compact")
92+
})
93+
94+
test("preserves current hook names unchanged", () => {
95+
// #given: Config with current hook names
96+
const hooks = [
97+
"anthropic-context-window-limit-recovery",
98+
"todo-continuation-enforcer",
99+
"session-recovery",
100+
]
101+
102+
// #when: Migrate hook names
103+
const { migrated, changed } = migrateHookNames(hooks)
104+
105+
// #then: Current names should remain unchanged
106+
expect(changed).toBe(false)
107+
expect(migrated).toEqual(hooks)
108+
})
109+
110+
test("handles empty hooks array", () => {
111+
// #given: Empty hooks array
112+
const hooks: string[] = []
113+
114+
// #when: Migrate hook names
115+
const { migrated, changed } = migrateHookNames(hooks)
116+
117+
// #then: Should return empty array with no changes
118+
expect(changed).toBe(false)
119+
expect(migrated).toEqual([])
120+
})
121+
122+
test("migrates multiple legacy hook names", () => {
123+
// #given: Multiple legacy hook names (if more are added in future)
124+
const hooks = ["anthropic-auto-compact"]
125+
126+
// #when: Migrate hook names
127+
const { migrated, changed } = migrateHookNames(hooks)
128+
129+
// #then: All legacy names should be migrated
130+
expect(changed).toBe(true)
131+
expect(migrated).toEqual(["anthropic-context-window-limit-recovery"])
132+
})
133+
})
134+
135+
describe("migrateConfigFile", () => {
136+
const testConfigPath = "/tmp/nonexistent-path-for-test.json"
137+
138+
test("migrates omo_agent to sisyphus_agent", () => {
139+
// #given: Config with legacy omo_agent key
140+
const rawConfig: Record<string, unknown> = {
141+
omo_agent: { disabled: false },
142+
}
143+
144+
// #when: Migrate config file
145+
const needsWrite = migrateConfigFile(testConfigPath, rawConfig)
146+
147+
// #then: omo_agent should be migrated to sisyphus_agent
148+
expect(needsWrite).toBe(true)
149+
expect(rawConfig.sisyphus_agent).toEqual({ disabled: false })
150+
expect(rawConfig.omo_agent).toBeUndefined()
151+
})
152+
153+
test("migrates legacy agent names in agents object", () => {
154+
// #given: Config with legacy agent names
155+
const rawConfig: Record<string, unknown> = {
156+
agents: {
157+
omo: { model: "test" },
158+
OmO: { temperature: 0.5 },
159+
},
160+
}
161+
162+
// #when: Migrate config file
163+
const needsWrite = migrateConfigFile(testConfigPath, rawConfig)
164+
165+
// #then: Agent names should be migrated
166+
expect(needsWrite).toBe(true)
167+
const agents = rawConfig.agents as Record<string, unknown>
168+
expect(agents["Sisyphus"]).toBeDefined()
169+
})
170+
171+
test("migrates legacy hook names in disabled_hooks", () => {
172+
// #given: Config with legacy hook names
173+
const rawConfig: Record<string, unknown> = {
174+
disabled_hooks: ["anthropic-auto-compact", "comment-checker"],
175+
}
176+
177+
// #when: Migrate config file
178+
const needsWrite = migrateConfigFile(testConfigPath, rawConfig)
179+
180+
// #then: Hook names should be migrated
181+
expect(needsWrite).toBe(true)
182+
expect(rawConfig.disabled_hooks).toContain("anthropic-context-window-limit-recovery")
183+
expect(rawConfig.disabled_hooks).not.toContain("anthropic-auto-compact")
184+
})
185+
186+
test("does not write if no migration needed", () => {
187+
// #given: Config with current names
188+
const rawConfig: Record<string, unknown> = {
189+
sisyphus_agent: { disabled: false },
190+
agents: {
191+
Sisyphus: { model: "test" },
192+
},
193+
disabled_hooks: ["anthropic-context-window-limit-recovery"],
194+
}
195+
196+
// #when: Migrate config file
197+
const needsWrite = migrateConfigFile(testConfigPath, rawConfig)
198+
199+
// #then: No write should be needed
200+
expect(needsWrite).toBe(false)
201+
})
202+
203+
test("handles migration of all legacy items together", () => {
204+
// #given: Config with all legacy items
205+
const rawConfig: Record<string, unknown> = {
206+
omo_agent: { disabled: false },
207+
agents: {
208+
omo: { model: "test" },
209+
"OmO-Plan": { prompt: "custom" },
210+
},
211+
disabled_hooks: ["anthropic-auto-compact"],
212+
}
213+
214+
// #when: Migrate config file
215+
const needsWrite = migrateConfigFile(testConfigPath, rawConfig)
216+
217+
// #then: All legacy items should be migrated
218+
expect(needsWrite).toBe(true)
219+
expect(rawConfig.sisyphus_agent).toEqual({ disabled: false })
220+
expect(rawConfig.omo_agent).toBeUndefined()
221+
const agents = rawConfig.agents as Record<string, unknown>
222+
expect(agents["Sisyphus"]).toBeDefined()
223+
expect(agents["Planner-Sisyphus"]).toBeDefined()
224+
expect(rawConfig.disabled_hooks).toContain("anthropic-context-window-limit-recovery")
225+
})
226+
})
227+
228+
describe("migration maps", () => {
229+
test("AGENT_NAME_MAP contains all expected legacy mappings", () => {
230+
// #given/#when: Check AGENT_NAME_MAP
231+
// #then: Should contain all legacy → current mappings
232+
expect(AGENT_NAME_MAP["omo"]).toBe("Sisyphus")
233+
expect(AGENT_NAME_MAP["OmO"]).toBe("Sisyphus")
234+
expect(AGENT_NAME_MAP["OmO-Plan"]).toBe("Planner-Sisyphus")
235+
expect(AGENT_NAME_MAP["omo-plan"]).toBe("Planner-Sisyphus")
236+
})
237+
238+
test("HOOK_NAME_MAP contains anthropic-auto-compact migration", () => {
239+
// #given/#when: Check HOOK_NAME_MAP
240+
// #then: Should contain the legacy hook name mapping
241+
expect(HOOK_NAME_MAP["anthropic-auto-compact"]).toBe("anthropic-context-window-limit-recovery")
242+
})
243+
})

src/shared/migration.ts

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
import * as fs from "fs"
2+
import { log } from "./logger"
3+
4+
// Migration map: old keys → new keys (for backward compatibility)
5+
export const AGENT_NAME_MAP: Record<string, string> = {
6+
// Legacy names (backward compatibility)
7+
omo: "Sisyphus",
8+
"OmO": "Sisyphus",
9+
"OmO-Plan": "Planner-Sisyphus",
10+
"omo-plan": "Planner-Sisyphus",
11+
// Current names
12+
sisyphus: "Sisyphus",
13+
"planner-sisyphus": "Planner-Sisyphus",
14+
build: "build",
15+
oracle: "oracle",
16+
librarian: "librarian",
17+
explore: "explore",
18+
"frontend-ui-ux-engineer": "frontend-ui-ux-engineer",
19+
"document-writer": "document-writer",
20+
"multimodal-looker": "multimodal-looker",
21+
}
22+
23+
// Migration map: old hook names → new hook names (for backward compatibility)
24+
export const HOOK_NAME_MAP: Record<string, string> = {
25+
// Legacy names (backward compatibility)
26+
"anthropic-auto-compact": "anthropic-context-window-limit-recovery",
27+
}
28+
29+
export function migrateAgentNames(agents: Record<string, unknown>): { migrated: Record<string, unknown>; changed: boolean } {
30+
const migrated: Record<string, unknown> = {}
31+
let changed = false
32+
33+
for (const [key, value] of Object.entries(agents)) {
34+
const newKey = AGENT_NAME_MAP[key.toLowerCase()] ?? AGENT_NAME_MAP[key] ?? key
35+
if (newKey !== key) {
36+
changed = true
37+
}
38+
migrated[newKey] = value
39+
}
40+
41+
return { migrated, changed }
42+
}
43+
44+
export function migrateHookNames(hooks: string[]): { migrated: string[]; changed: boolean } {
45+
const migrated: string[] = []
46+
let changed = false
47+
48+
for (const hook of hooks) {
49+
const newHook = HOOK_NAME_MAP[hook] ?? hook
50+
if (newHook !== hook) {
51+
changed = true
52+
}
53+
migrated.push(newHook)
54+
}
55+
56+
return { migrated, changed }
57+
}
58+
59+
export function migrateConfigFile(configPath: string, rawConfig: Record<string, unknown>): boolean {
60+
let needsWrite = false
61+
62+
if (rawConfig.agents && typeof rawConfig.agents === "object") {
63+
const { migrated, changed } = migrateAgentNames(rawConfig.agents as Record<string, unknown>)
64+
if (changed) {
65+
rawConfig.agents = migrated
66+
needsWrite = true
67+
}
68+
}
69+
70+
if (rawConfig.omo_agent) {
71+
rawConfig.sisyphus_agent = rawConfig.omo_agent
72+
delete rawConfig.omo_agent
73+
needsWrite = true
74+
}
75+
76+
if (rawConfig.disabled_hooks && Array.isArray(rawConfig.disabled_hooks)) {
77+
const { migrated, changed } = migrateHookNames(rawConfig.disabled_hooks as string[])
78+
if (changed) {
79+
rawConfig.disabled_hooks = migrated
80+
needsWrite = true
81+
}
82+
}
83+
84+
if (needsWrite) {
85+
try {
86+
fs.writeFileSync(configPath, JSON.stringify(rawConfig, null, 2) + "\n", "utf-8")
87+
log(`Migrated config file: ${configPath}`)
88+
} catch (err) {
89+
log(`Failed to write migrated config to ${configPath}:`, err)
90+
}
91+
}
92+
93+
return needsWrite
94+
}

0 commit comments

Comments
 (0)