2026-03-14 13:43:56 +09:00
|
|
|
/// <reference types="bun-types" />
|
|
|
|
|
|
2026-01-09 02:24:43 +09:00
|
|
|
import { describe, test, expect, afterEach } from "bun:test"
|
|
|
|
|
import * as fs from "fs"
|
|
|
|
|
import * as path from "path"
|
2025-12-31 12:41:01 +09:00
|
|
|
import {
|
|
|
|
|
AGENT_NAME_MAP,
|
|
|
|
|
HOOK_NAME_MAP,
|
2026-02-06 15:55:28 +09:00
|
|
|
MODEL_VERSION_MAP,
|
2025-12-31 12:41:01 +09:00
|
|
|
migrateAgentNames,
|
|
|
|
|
migrateHookNames,
|
2026-02-06 15:55:28 +09:00
|
|
|
migrateModelVersions,
|
2025-12-31 12:41:01 +09:00
|
|
|
migrateConfigFile,
|
2026-01-09 02:24:43 +09:00
|
|
|
migrateAgentConfigToCategory,
|
|
|
|
|
shouldDeleteAgentConfig,
|
2025-12-31 12:41:01 +09:00
|
|
|
} from "./migration"
|
|
|
|
|
|
|
|
|
|
describe("migrateAgentNames", () => {
|
2026-01-23 19:01:10 +09:00
|
|
|
test("migrates legacy OmO names to lowercase", () => {
|
2026-02-01 16:47:50 +09:00
|
|
|
// given: Config with legacy OmO agent names
|
2025-12-31 12:41:01 +09:00
|
|
|
const agents = {
|
2026-02-06 14:21:15 +09:00
|
|
|
omo: { model: "anthropic/claude-opus-4-6" },
|
2025-12-31 12:41:01 +09:00
|
|
|
OmO: { temperature: 0.5 },
|
|
|
|
|
"OmO-Plan": { prompt: "custom prompt" },
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-01 16:47:50 +09:00
|
|
|
// when: Migrate agent names
|
2025-12-31 12:41:01 +09:00
|
|
|
const { migrated, changed } = migrateAgentNames(agents)
|
|
|
|
|
|
2026-02-01 16:47:50 +09:00
|
|
|
// then: Legacy names should be migrated to lowercase
|
2025-12-31 12:41:01 +09:00
|
|
|
expect(changed).toBe(true)
|
2026-01-23 19:01:10 +09:00
|
|
|
expect(migrated["sisyphus"]).toEqual({ temperature: 0.5 })
|
|
|
|
|
expect(migrated["prometheus"]).toEqual({ prompt: "custom prompt" })
|
2025-12-31 12:41:01 +09:00
|
|
|
expect(migrated["omo"]).toBeUndefined()
|
|
|
|
|
expect(migrated["OmO"]).toBeUndefined()
|
|
|
|
|
expect(migrated["OmO-Plan"]).toBeUndefined()
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
test("preserves current agent names unchanged", () => {
|
2026-02-01 16:47:50 +09:00
|
|
|
// given: Config with current agent names
|
2025-12-31 12:41:01 +09:00
|
|
|
const agents = {
|
2026-03-07 02:33:32 +09:00
|
|
|
oracle: { model: "openai/gpt-5.4" },
|
2025-12-31 12:41:01 +09:00
|
|
|
librarian: { model: "google/gemini-3-flash" },
|
2026-01-24 15:30:35 +09:00
|
|
|
explore: { model: "opencode/gpt-5-nano" },
|
2025-12-31 12:41:01 +09:00
|
|
|
}
|
|
|
|
|
|
2026-02-01 16:47:50 +09:00
|
|
|
// when: Migrate agent names
|
2025-12-31 12:41:01 +09:00
|
|
|
const { migrated, changed } = migrateAgentNames(agents)
|
|
|
|
|
|
2026-02-01 16:47:50 +09:00
|
|
|
// then: Current names should remain unchanged
|
2025-12-31 12:41:01 +09:00
|
|
|
expect(changed).toBe(false)
|
2026-03-07 02:33:32 +09:00
|
|
|
expect(migrated["oracle"]).toEqual({ model: "openai/gpt-5.4" })
|
2025-12-31 12:41:01 +09:00
|
|
|
expect(migrated["librarian"]).toEqual({ model: "google/gemini-3-flash" })
|
2026-01-24 15:30:35 +09:00
|
|
|
expect(migrated["explore"]).toEqual({ model: "opencode/gpt-5-nano" })
|
2025-12-31 12:41:01 +09:00
|
|
|
})
|
|
|
|
|
|
|
|
|
|
test("handles case-insensitive migration", () => {
|
2026-02-01 16:47:50 +09:00
|
|
|
// given: Config with mixed case agent names
|
2025-12-31 12:41:01 +09:00
|
|
|
const agents = {
|
|
|
|
|
SISYPHUS: { model: "test" },
|
2026-01-09 02:24:43 +09:00
|
|
|
"planner-sisyphus": { prompt: "test" },
|
2026-03-07 02:33:32 +09:00
|
|
|
"Orchestrator-Sisyphus": { model: "openai/gpt-5.4" },
|
2025-12-31 12:41:01 +09:00
|
|
|
}
|
|
|
|
|
|
2026-02-01 16:47:50 +09:00
|
|
|
// when: Migrate agent names
|
2025-12-31 12:41:01 +09:00
|
|
|
const { migrated, changed } = migrateAgentNames(agents)
|
|
|
|
|
|
2026-02-01 16:47:50 +09:00
|
|
|
// then: Case-insensitive lookup should migrate correctly
|
2026-01-23 19:01:10 +09:00
|
|
|
expect(migrated["sisyphus"]).toEqual({ model: "test" })
|
|
|
|
|
expect(migrated["prometheus"]).toEqual({ prompt: "test" })
|
2026-03-07 02:33:32 +09:00
|
|
|
expect(migrated["atlas"]).toEqual({ model: "openai/gpt-5.4" })
|
2025-12-31 12:41:01 +09:00
|
|
|
})
|
|
|
|
|
|
|
|
|
|
test("passes through unknown agent names unchanged", () => {
|
2026-02-01 16:47:50 +09:00
|
|
|
// given: Config with unknown agent name
|
2025-12-31 12:41:01 +09:00
|
|
|
const agents = {
|
|
|
|
|
"custom-agent": { model: "custom/model" },
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-01 16:47:50 +09:00
|
|
|
// when: Migrate agent names
|
2025-12-31 12:41:01 +09:00
|
|
|
const { migrated, changed } = migrateAgentNames(agents)
|
|
|
|
|
|
2026-02-01 16:47:50 +09:00
|
|
|
// then: Unknown names should pass through
|
2025-12-31 12:41:01 +09:00
|
|
|
expect(changed).toBe(false)
|
|
|
|
|
expect(migrated["custom-agent"]).toEqual({ model: "custom/model" })
|
|
|
|
|
})
|
2026-01-20 15:24:31 +09:00
|
|
|
|
2026-01-23 19:01:10 +09:00
|
|
|
test("migrates orchestrator-sisyphus to atlas", () => {
|
2026-02-01 16:47:50 +09:00
|
|
|
// given: Config with legacy orchestrator-sisyphus agent name
|
2026-01-20 15:24:31 +09:00
|
|
|
const agents = {
|
2026-02-06 14:21:15 +09:00
|
|
|
"orchestrator-sisyphus": { model: "anthropic/claude-opus-4-6" },
|
2026-01-20 15:24:31 +09:00
|
|
|
}
|
|
|
|
|
|
2026-02-01 16:47:50 +09:00
|
|
|
// when: Migrate agent names
|
2026-01-20 15:24:31 +09:00
|
|
|
const { migrated, changed } = migrateAgentNames(agents)
|
|
|
|
|
|
2026-02-01 16:47:50 +09:00
|
|
|
// then: orchestrator-sisyphus should be migrated to atlas
|
2026-01-20 15:24:31 +09:00
|
|
|
expect(changed).toBe(true)
|
2026-02-06 14:21:15 +09:00
|
|
|
expect(migrated["atlas"]).toEqual({ model: "anthropic/claude-opus-4-6" })
|
2026-01-20 15:24:31 +09:00
|
|
|
expect(migrated["orchestrator-sisyphus"]).toBeUndefined()
|
|
|
|
|
})
|
2026-01-20 19:10:21 +09:00
|
|
|
|
2026-01-23 19:01:10 +09:00
|
|
|
test("migrates lowercase atlas to atlas", () => {
|
2026-02-01 16:47:50 +09:00
|
|
|
// given: Config with lowercase atlas agent name
|
2026-01-20 19:10:21 +09:00
|
|
|
const agents = {
|
2026-02-06 14:21:15 +09:00
|
|
|
atlas: { model: "anthropic/claude-opus-4-6" },
|
2026-01-20 19:10:21 +09:00
|
|
|
}
|
|
|
|
|
|
2026-02-01 16:47:50 +09:00
|
|
|
// when: Migrate agent names
|
2026-01-20 19:10:21 +09:00
|
|
|
const { migrated, changed } = migrateAgentNames(agents)
|
|
|
|
|
|
2026-02-01 16:47:50 +09:00
|
|
|
// then: lowercase atlas should remain atlas (no change needed)
|
2026-01-23 19:01:10 +09:00
|
|
|
expect(changed).toBe(false)
|
2026-02-06 14:21:15 +09:00
|
|
|
expect(migrated["atlas"]).toEqual({ model: "anthropic/claude-opus-4-6" })
|
2026-01-23 19:01:10 +09:00
|
|
|
})
|
|
|
|
|
|
|
|
|
|
test("migrates Sisyphus variants to lowercase", () => {
|
2026-02-01 16:47:50 +09:00
|
|
|
// given agents config with "Sisyphus" key
|
|
|
|
|
// when migrateAgentNames called
|
|
|
|
|
// then key becomes "sisyphus"
|
2026-01-23 19:01:10 +09:00
|
|
|
const agents = { "Sisyphus": { model: "test" } }
|
|
|
|
|
const { migrated, changed } = migrateAgentNames(agents)
|
|
|
|
|
expect(changed).toBe(true)
|
|
|
|
|
expect(migrated["sisyphus"]).toEqual({ model: "test" })
|
|
|
|
|
expect(migrated["Sisyphus"]).toBeUndefined()
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
test("migrates omo key to sisyphus", () => {
|
2026-02-01 16:47:50 +09:00
|
|
|
// given agents config with "omo" key
|
|
|
|
|
// when migrateAgentNames called
|
|
|
|
|
// then key becomes "sisyphus"
|
2026-01-23 19:01:10 +09:00
|
|
|
const agents = { "omo": { model: "test" } }
|
|
|
|
|
const { migrated, changed } = migrateAgentNames(agents)
|
|
|
|
|
expect(changed).toBe(true)
|
|
|
|
|
expect(migrated["sisyphus"]).toEqual({ model: "test" })
|
|
|
|
|
expect(migrated["omo"]).toBeUndefined()
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
test("migrates Atlas variants to lowercase", () => {
|
2026-02-01 16:47:50 +09:00
|
|
|
// given agents config with "Atlas" key
|
|
|
|
|
// when migrateAgentNames called
|
|
|
|
|
// then key becomes "atlas"
|
2026-01-23 19:01:10 +09:00
|
|
|
const agents = { "Atlas": { model: "test" } }
|
|
|
|
|
const { migrated, changed } = migrateAgentNames(agents)
|
|
|
|
|
expect(changed).toBe(true)
|
|
|
|
|
expect(migrated["atlas"]).toEqual({ model: "test" })
|
|
|
|
|
expect(migrated["Atlas"]).toBeUndefined()
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
test("migrates Prometheus variants to lowercase", () => {
|
2026-04-07 15:30:51 +09:00
|
|
|
// given agents config with "Prometheus - Plan Builder" key
|
2026-02-01 16:47:50 +09:00
|
|
|
// when migrateAgentNames called
|
|
|
|
|
// then key becomes "prometheus"
|
2026-04-07 15:30:51 +09:00
|
|
|
const agents = { "Prometheus - Plan Builder": { model: "test" } }
|
2026-01-23 19:01:10 +09:00
|
|
|
const { migrated, changed } = migrateAgentNames(agents)
|
|
|
|
|
expect(changed).toBe(true)
|
|
|
|
|
expect(migrated["prometheus"]).toEqual({ model: "test" })
|
2026-04-07 15:30:51 +09:00
|
|
|
expect(migrated["Prometheus - Plan Builder"]).toBeUndefined()
|
2026-01-23 19:01:10 +09:00
|
|
|
})
|
|
|
|
|
|
|
|
|
|
test("migrates Metis variants to lowercase", () => {
|
2026-04-07 15:30:51 +09:00
|
|
|
// given agents config with "Metis - Plan Consultant" key
|
2026-02-01 16:47:50 +09:00
|
|
|
// when migrateAgentNames called
|
|
|
|
|
// then key becomes "metis"
|
2026-04-07 15:30:51 +09:00
|
|
|
const agents = { "Metis - Plan Consultant": { model: "test" } }
|
2026-01-23 19:01:10 +09:00
|
|
|
const { migrated, changed } = migrateAgentNames(agents)
|
|
|
|
|
expect(changed).toBe(true)
|
|
|
|
|
expect(migrated["metis"]).toEqual({ model: "test" })
|
2026-04-07 15:30:51 +09:00
|
|
|
expect(migrated["Metis - Plan Consultant"]).toBeUndefined()
|
2026-01-23 19:01:10 +09:00
|
|
|
})
|
|
|
|
|
|
|
|
|
|
test("migrates Momus variants to lowercase", () => {
|
2026-04-07 15:30:51 +09:00
|
|
|
// given agents config with "Momus - Plan Critic" key
|
2026-02-01 16:47:50 +09:00
|
|
|
// when migrateAgentNames called
|
|
|
|
|
// then key becomes "momus"
|
2026-04-07 15:30:51 +09:00
|
|
|
const agents = { "Momus - Plan Critic": { model: "test" } }
|
2026-01-23 19:01:10 +09:00
|
|
|
const { migrated, changed } = migrateAgentNames(agents)
|
2026-01-20 19:10:21 +09:00
|
|
|
expect(changed).toBe(true)
|
2026-01-23 19:01:10 +09:00
|
|
|
expect(migrated["momus"]).toEqual({ model: "test" })
|
2026-04-07 15:30:51 +09:00
|
|
|
expect(migrated["Momus - Plan Critic"]).toBeUndefined()
|
2026-01-23 19:01:10 +09:00
|
|
|
})
|
|
|
|
|
|
|
|
|
|
test("migrates Sisyphus-Junior to lowercase", () => {
|
2026-02-01 16:47:50 +09:00
|
|
|
// given agents config with "Sisyphus-Junior" key
|
|
|
|
|
// when migrateAgentNames called
|
|
|
|
|
// then key becomes "sisyphus-junior"
|
2026-01-23 19:01:10 +09:00
|
|
|
const agents = { "Sisyphus-Junior": { model: "test" } }
|
|
|
|
|
const { migrated, changed } = migrateAgentNames(agents)
|
|
|
|
|
expect(changed).toBe(true)
|
|
|
|
|
expect(migrated["sisyphus-junior"]).toEqual({ model: "test" })
|
|
|
|
|
expect(migrated["Sisyphus-Junior"]).toBeUndefined()
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
test("preserves lowercase passthrough", () => {
|
2026-02-01 16:47:50 +09:00
|
|
|
// given agents config with "oracle" key
|
|
|
|
|
// when migrateAgentNames called
|
|
|
|
|
// then key remains "oracle" (no change needed)
|
2026-01-23 19:01:10 +09:00
|
|
|
const agents = { "oracle": { model: "test" } }
|
|
|
|
|
const { migrated, changed } = migrateAgentNames(agents)
|
|
|
|
|
expect(changed).toBe(false)
|
|
|
|
|
expect(migrated["oracle"]).toEqual({ model: "test" })
|
2026-01-20 19:10:21 +09:00
|
|
|
})
|
2025-12-31 12:41:01 +09:00
|
|
|
})
|
|
|
|
|
|
|
|
|
|
describe("migrateHookNames", () => {
|
|
|
|
|
test("migrates anthropic-auto-compact to anthropic-context-window-limit-recovery", () => {
|
2026-02-01 16:47:50 +09:00
|
|
|
// given: Config with legacy hook name
|
2025-12-31 12:41:01 +09:00
|
|
|
const hooks = ["anthropic-auto-compact", "comment-checker"]
|
|
|
|
|
|
2026-02-01 16:47:50 +09:00
|
|
|
// when: Migrate hook names
|
2026-01-23 01:24:07 +09:00
|
|
|
const { migrated, changed, removed } = migrateHookNames(hooks)
|
2025-12-31 12:41:01 +09:00
|
|
|
|
2026-02-01 16:47:50 +09:00
|
|
|
// then: Legacy hook name should be migrated
|
2025-12-31 12:41:01 +09:00
|
|
|
expect(changed).toBe(true)
|
|
|
|
|
expect(migrated).toContain("anthropic-context-window-limit-recovery")
|
|
|
|
|
expect(migrated).toContain("comment-checker")
|
|
|
|
|
expect(migrated).not.toContain("anthropic-auto-compact")
|
2026-01-23 01:24:07 +09:00
|
|
|
expect(removed).toEqual([])
|
2025-12-31 12:41:01 +09:00
|
|
|
})
|
|
|
|
|
|
|
|
|
|
test("preserves current hook names unchanged", () => {
|
2026-02-01 16:47:50 +09:00
|
|
|
// given: Config with current hook names
|
2025-12-31 12:41:01 +09:00
|
|
|
const hooks = [
|
|
|
|
|
"anthropic-context-window-limit-recovery",
|
|
|
|
|
"todo-continuation-enforcer",
|
|
|
|
|
"session-recovery",
|
|
|
|
|
]
|
|
|
|
|
|
2026-02-01 16:47:50 +09:00
|
|
|
// when: Migrate hook names
|
2026-01-23 01:24:07 +09:00
|
|
|
const { migrated, changed, removed } = migrateHookNames(hooks)
|
2025-12-31 12:41:01 +09:00
|
|
|
|
2026-02-01 16:47:50 +09:00
|
|
|
// then: Current names should remain unchanged
|
2025-12-31 12:41:01 +09:00
|
|
|
expect(changed).toBe(false)
|
|
|
|
|
expect(migrated).toEqual(hooks)
|
2026-01-23 01:24:07 +09:00
|
|
|
expect(removed).toEqual([])
|
2025-12-31 12:41:01 +09:00
|
|
|
})
|
|
|
|
|
|
|
|
|
|
test("handles empty hooks array", () => {
|
2026-02-01 16:47:50 +09:00
|
|
|
// given: Empty hooks array
|
2025-12-31 12:41:01 +09:00
|
|
|
const hooks: string[] = []
|
|
|
|
|
|
2026-02-01 16:47:50 +09:00
|
|
|
// when: Migrate hook names
|
2026-01-23 01:24:07 +09:00
|
|
|
const { migrated, changed, removed } = migrateHookNames(hooks)
|
2025-12-31 12:41:01 +09:00
|
|
|
|
2026-02-01 16:47:50 +09:00
|
|
|
// then: Should return empty array with no changes
|
2025-12-31 12:41:01 +09:00
|
|
|
expect(changed).toBe(false)
|
|
|
|
|
expect(migrated).toEqual([])
|
2026-01-23 01:24:07 +09:00
|
|
|
expect(removed).toEqual([])
|
2025-12-31 12:41:01 +09:00
|
|
|
})
|
|
|
|
|
|
|
|
|
|
test("migrates multiple legacy hook names", () => {
|
2026-02-01 16:47:50 +09:00
|
|
|
// given: Multiple legacy hook names (if more are added in future)
|
2025-12-31 12:41:01 +09:00
|
|
|
const hooks = ["anthropic-auto-compact"]
|
|
|
|
|
|
2026-02-01 16:47:50 +09:00
|
|
|
// when: Migrate hook names
|
2025-12-31 12:41:01 +09:00
|
|
|
const { migrated, changed } = migrateHookNames(hooks)
|
|
|
|
|
|
2026-02-01 16:47:50 +09:00
|
|
|
// then: All legacy names should be migrated
|
2025-12-31 12:41:01 +09:00
|
|
|
expect(changed).toBe(true)
|
|
|
|
|
expect(migrated).toEqual(["anthropic-context-window-limit-recovery"])
|
|
|
|
|
})
|
2026-01-23 01:24:07 +09:00
|
|
|
|
|
|
|
|
test("migrates sisyphus-orchestrator to atlas", () => {
|
2026-02-01 16:47:50 +09:00
|
|
|
// given: Config with legacy sisyphus-orchestrator hook
|
2026-01-23 01:24:07 +09:00
|
|
|
const hooks = ["sisyphus-orchestrator", "comment-checker"]
|
|
|
|
|
|
2026-02-01 16:47:50 +09:00
|
|
|
// when: Migrate hook names
|
2026-01-23 01:24:07 +09:00
|
|
|
const { migrated, changed, removed } = migrateHookNames(hooks)
|
|
|
|
|
|
2026-02-01 16:47:50 +09:00
|
|
|
// then: sisyphus-orchestrator should be migrated to atlas
|
2026-01-23 01:24:07 +09:00
|
|
|
expect(changed).toBe(true)
|
|
|
|
|
expect(migrated).toContain("atlas")
|
|
|
|
|
expect(migrated).toContain("comment-checker")
|
|
|
|
|
expect(migrated).not.toContain("sisyphus-orchestrator")
|
|
|
|
|
expect(removed).toEqual([])
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
test("removes obsolete hooks and returns them in removed array", () => {
|
2026-02-01 16:47:50 +09:00
|
|
|
// given: Config with removed hooks from v3.0.0
|
2026-01-23 01:24:07 +09:00
|
|
|
const hooks = ["preemptive-compaction", "empty-message-sanitizer", "comment-checker"]
|
|
|
|
|
|
2026-02-01 16:47:50 +09:00
|
|
|
// when: Migrate hook names
|
2026-01-23 01:24:07 +09:00
|
|
|
const { migrated, changed, removed } = migrateHookNames(hooks)
|
|
|
|
|
|
2026-02-01 16:47:50 +09:00
|
|
|
// then: Removed hooks should be filtered out
|
2026-01-23 01:24:07 +09:00
|
|
|
expect(changed).toBe(true)
|
2026-02-02 22:40:59 +09:00
|
|
|
expect(migrated).toEqual(["preemptive-compaction", "comment-checker"])
|
2026-01-23 01:24:07 +09:00
|
|
|
expect(removed).toContain("empty-message-sanitizer")
|
2026-02-02 22:40:59 +09:00
|
|
|
expect(removed).toHaveLength(1)
|
2026-01-23 01:24:07 +09:00
|
|
|
})
|
|
|
|
|
|
2026-03-19 11:37:30 +09:00
|
|
|
test("removes gpt-permission-continuation from disabled hooks", () => {
|
|
|
|
|
// given: Config with removed GPT permission continuation hook
|
|
|
|
|
const hooks = ["gpt-permission-continuation", "comment-checker"]
|
|
|
|
|
|
|
|
|
|
// when: Migrate hook names
|
|
|
|
|
const { migrated, changed, removed } = migrateHookNames(hooks)
|
|
|
|
|
|
|
|
|
|
// then: Removed hook should be filtered out
|
|
|
|
|
expect(changed).toBe(true)
|
|
|
|
|
expect(migrated).toEqual(["comment-checker"])
|
|
|
|
|
expect(removed).toEqual(["gpt-permission-continuation"])
|
|
|
|
|
})
|
|
|
|
|
|
2026-01-23 01:24:07 +09:00
|
|
|
test("handles mixed migration and removal", () => {
|
2026-02-01 16:47:50 +09:00
|
|
|
// given: Config with both legacy rename and removed hooks
|
2026-01-23 01:24:07 +09:00
|
|
|
const hooks = ["anthropic-auto-compact", "preemptive-compaction", "sisyphus-orchestrator"]
|
|
|
|
|
|
2026-02-01 16:47:50 +09:00
|
|
|
// when: Migrate hook names
|
2026-01-23 01:24:07 +09:00
|
|
|
const { migrated, changed, removed } = migrateHookNames(hooks)
|
|
|
|
|
|
2026-02-01 16:47:50 +09:00
|
|
|
// then: Legacy should be renamed, removed should be filtered
|
2026-01-23 01:24:07 +09:00
|
|
|
expect(changed).toBe(true)
|
|
|
|
|
expect(migrated).toContain("anthropic-context-window-limit-recovery")
|
|
|
|
|
expect(migrated).toContain("atlas")
|
2026-02-02 22:40:59 +09:00
|
|
|
expect(migrated).toContain("preemptive-compaction")
|
|
|
|
|
expect(removed).toEqual([])
|
2026-01-23 01:24:07 +09:00
|
|
|
})
|
2025-12-31 12:41:01 +09:00
|
|
|
})
|
|
|
|
|
|
|
|
|
|
describe("migrateConfigFile", () => {
|
|
|
|
|
const testConfigPath = "/tmp/nonexistent-path-for-test.json"
|
|
|
|
|
|
2026-04-09 10:54:08 +09:00
|
|
|
// Tests in this block share a single config path and do not write a real
|
|
|
|
|
// config file, but migrateConfigFile now persists migration tracking to a
|
|
|
|
|
// sidecar next to the config (#3263). Clear the sidecar between tests so
|
|
|
|
|
// state from an earlier test does not bleed into the next one.
|
|
|
|
|
afterEach(() => {
|
|
|
|
|
try {
|
|
|
|
|
fs.unlinkSync(`${testConfigPath}.migrations.json`)
|
|
|
|
|
} catch {
|
|
|
|
|
// ignore — sidecar may not exist
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
|
2026-02-20 11:12:33 +09:00
|
|
|
test("migrates experimental.hashline_edit to top-level hashline_edit", () => {
|
|
|
|
|
// given: Config with legacy experimental.hashline_edit
|
|
|
|
|
const rawConfig: Record<string, unknown> = {
|
|
|
|
|
experimental: { hashline_edit: false, safe_hook_creation: true },
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// when: Migrate config file
|
|
|
|
|
const needsWrite = migrateConfigFile(testConfigPath, rawConfig)
|
|
|
|
|
|
|
|
|
|
// then: hashline_edit should move to top-level and be removed from experimental
|
|
|
|
|
expect(needsWrite).toBe(true)
|
|
|
|
|
expect(rawConfig.hashline_edit).toBe(false)
|
|
|
|
|
expect(rawConfig.experimental).toEqual({ safe_hook_creation: true })
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
test("migrates and removes empty experimental object", () => {
|
|
|
|
|
// given: Config with only experimental.hashline_edit
|
|
|
|
|
const rawConfig: Record<string, unknown> = {
|
|
|
|
|
experimental: { hashline_edit: true },
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// when: Migrate config file
|
|
|
|
|
const needsWrite = migrateConfigFile(testConfigPath, rawConfig)
|
|
|
|
|
|
|
|
|
|
// then: hashline_edit moves top-level and empty experimental is removed
|
|
|
|
|
expect(needsWrite).toBe(true)
|
|
|
|
|
expect(rawConfig.hashline_edit).toBe(true)
|
|
|
|
|
expect(rawConfig.experimental).toBeUndefined()
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
test("does not overwrite top-level hashline_edit when already set", () => {
|
|
|
|
|
// given: Config with both top-level and legacy location
|
|
|
|
|
const rawConfig: Record<string, unknown> = {
|
|
|
|
|
hashline_edit: false,
|
|
|
|
|
experimental: { hashline_edit: true },
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// when: Migrate config file
|
|
|
|
|
const needsWrite = migrateConfigFile(testConfigPath, rawConfig)
|
|
|
|
|
|
|
|
|
|
// then: top-level value wins, legacy key removed
|
|
|
|
|
expect(needsWrite).toBe(true)
|
|
|
|
|
expect(rawConfig.hashline_edit).toBe(false)
|
|
|
|
|
expect(rawConfig.experimental).toBeUndefined()
|
|
|
|
|
})
|
|
|
|
|
|
2025-12-31 12:41:01 +09:00
|
|
|
test("migrates omo_agent to sisyphus_agent", () => {
|
2026-02-01 16:47:50 +09:00
|
|
|
// given: Config with legacy omo_agent key
|
2025-12-31 12:41:01 +09:00
|
|
|
const rawConfig: Record<string, unknown> = {
|
|
|
|
|
omo_agent: { disabled: false },
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-01 16:47:50 +09:00
|
|
|
// when: Migrate config file
|
2025-12-31 12:41:01 +09:00
|
|
|
const needsWrite = migrateConfigFile(testConfigPath, rawConfig)
|
|
|
|
|
|
2026-02-01 16:47:50 +09:00
|
|
|
// then: omo_agent should be migrated to sisyphus_agent
|
2025-12-31 12:41:01 +09:00
|
|
|
expect(needsWrite).toBe(true)
|
|
|
|
|
expect(rawConfig.sisyphus_agent).toEqual({ disabled: false })
|
|
|
|
|
expect(rawConfig.omo_agent).toBeUndefined()
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
test("migrates legacy agent names in agents object", () => {
|
2026-02-01 16:47:50 +09:00
|
|
|
// given: Config with legacy agent names
|
2025-12-31 12:41:01 +09:00
|
|
|
const rawConfig: Record<string, unknown> = {
|
|
|
|
|
agents: {
|
|
|
|
|
omo: { model: "test" },
|
|
|
|
|
OmO: { temperature: 0.5 },
|
|
|
|
|
},
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-01 16:47:50 +09:00
|
|
|
// when: Migrate config file
|
2025-12-31 12:41:01 +09:00
|
|
|
const needsWrite = migrateConfigFile(testConfigPath, rawConfig)
|
|
|
|
|
|
2026-02-01 16:47:50 +09:00
|
|
|
// then: Agent names should be migrated
|
2025-12-31 12:41:01 +09:00
|
|
|
expect(needsWrite).toBe(true)
|
|
|
|
|
const agents = rawConfig.agents as Record<string, unknown>
|
2026-01-23 19:01:10 +09:00
|
|
|
expect(agents["sisyphus"]).toBeDefined()
|
2025-12-31 12:41:01 +09:00
|
|
|
})
|
|
|
|
|
|
|
|
|
|
test("migrates legacy hook names in disabled_hooks", () => {
|
2026-02-01 16:47:50 +09:00
|
|
|
// given: Config with legacy hook names
|
2025-12-31 12:41:01 +09:00
|
|
|
const rawConfig: Record<string, unknown> = {
|
|
|
|
|
disabled_hooks: ["anthropic-auto-compact", "comment-checker"],
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-01 16:47:50 +09:00
|
|
|
// when: Migrate config file
|
2025-12-31 12:41:01 +09:00
|
|
|
const needsWrite = migrateConfigFile(testConfigPath, rawConfig)
|
|
|
|
|
|
2026-02-01 16:47:50 +09:00
|
|
|
// then: Hook names should be migrated
|
2025-12-31 12:41:01 +09:00
|
|
|
expect(needsWrite).toBe(true)
|
|
|
|
|
expect(rawConfig.disabled_hooks).toContain("anthropic-context-window-limit-recovery")
|
|
|
|
|
expect(rawConfig.disabled_hooks).not.toContain("anthropic-auto-compact")
|
|
|
|
|
})
|
|
|
|
|
|
2026-03-14 13:43:56 +09:00
|
|
|
test("removes deleted hook names from disabled_hooks", () => {
|
|
|
|
|
const rawConfig: Record<string, unknown> = {
|
|
|
|
|
disabled_hooks: ["delegate-task-english-directive", "comment-checker"],
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const needsWrite = migrateConfigFile(testConfigPath, rawConfig)
|
|
|
|
|
|
|
|
|
|
expect(needsWrite).toBe(true)
|
|
|
|
|
expect(rawConfig.disabled_hooks).toEqual(["comment-checker"])
|
|
|
|
|
})
|
|
|
|
|
|
2026-03-19 11:37:30 +09:00
|
|
|
test("removes gpt-permission-continuation from disabled_hooks", () => {
|
|
|
|
|
// given: Config with removed GPT permission continuation hook
|
|
|
|
|
const rawConfig: Record<string, unknown> = {
|
|
|
|
|
disabled_hooks: ["gpt-permission-continuation", "comment-checker"],
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// when: Migrate config file
|
|
|
|
|
const needsWrite = migrateConfigFile(testConfigPath, rawConfig)
|
|
|
|
|
|
|
|
|
|
// then: Removed hook should be filtered out
|
|
|
|
|
expect(needsWrite).toBe(true)
|
|
|
|
|
expect(rawConfig.disabled_hooks).toEqual(["comment-checker"])
|
|
|
|
|
})
|
|
|
|
|
|
2025-12-31 12:41:01 +09:00
|
|
|
test("does not write if no migration needed", () => {
|
2026-02-01 16:47:50 +09:00
|
|
|
// given: Config with current names
|
2025-12-31 12:41:01 +09:00
|
|
|
const rawConfig: Record<string, unknown> = {
|
|
|
|
|
sisyphus_agent: { disabled: false },
|
|
|
|
|
agents: {
|
2026-01-23 19:01:10 +09:00
|
|
|
sisyphus: { model: "test" },
|
2025-12-31 12:41:01 +09:00
|
|
|
},
|
|
|
|
|
disabled_hooks: ["anthropic-context-window-limit-recovery"],
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-01 16:47:50 +09:00
|
|
|
// when: Migrate config file
|
2025-12-31 12:41:01 +09:00
|
|
|
const needsWrite = migrateConfigFile(testConfigPath, rawConfig)
|
|
|
|
|
|
2026-02-01 16:47:50 +09:00
|
|
|
// then: No write should be needed
|
2025-12-31 12:41:01 +09:00
|
|
|
expect(needsWrite).toBe(false)
|
|
|
|
|
})
|
|
|
|
|
|
2026-02-06 15:55:28 +09:00
|
|
|
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["prometheus"]).toBeDefined()
|
|
|
|
|
expect(rawConfig.disabled_hooks).toContain("anthropic-context-window-limit-recovery")
|
|
|
|
|
})
|
|
|
|
|
|
2026-03-07 02:33:32 +09:00
|
|
|
test("does not migrate gpt-5.4-codex model versions in agents", () => {
|
2026-02-06 15:55:28 +09:00
|
|
|
// given: Config with old model version in agents
|
|
|
|
|
const rawConfig: Record<string, unknown> = {
|
|
|
|
|
agents: {
|
2026-03-07 02:33:32 +09:00
|
|
|
sisyphus: { model: "openai/gpt-5.4-codex", temperature: 0.1 },
|
2026-02-06 15:55:28 +09:00
|
|
|
},
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// when: Migrate config file
|
|
|
|
|
const needsWrite = migrateConfigFile(testConfigPath, rawConfig)
|
|
|
|
|
|
2026-02-21 02:22:45 +09:00
|
|
|
// then: Model version should remain unchanged
|
|
|
|
|
expect(needsWrite).toBe(false)
|
2026-02-06 15:55:28 +09:00
|
|
|
const agents = rawConfig.agents as Record<string, Record<string, unknown>>
|
2026-03-07 02:33:32 +09:00
|
|
|
expect(agents["sisyphus"].model).toBe("openai/gpt-5.4-codex")
|
2026-02-06 15:55:28 +09:00
|
|
|
})
|
|
|
|
|
|
|
|
|
|
test("migrates model versions in categories", () => {
|
|
|
|
|
// given: Config with old model version in categories
|
|
|
|
|
const rawConfig: Record<string, unknown> = {
|
|
|
|
|
categories: {
|
|
|
|
|
"my-category": { model: "anthropic/claude-opus-4-5", temperature: 0.2 },
|
|
|
|
|
},
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// when: Migrate config file
|
|
|
|
|
const needsWrite = migrateConfigFile(testConfigPath, rawConfig)
|
|
|
|
|
|
|
|
|
|
// then: Model version should be migrated
|
|
|
|
|
expect(needsWrite).toBe(true)
|
|
|
|
|
const categories = rawConfig.categories as Record<string, Record<string, unknown>>
|
|
|
|
|
expect(categories["my-category"].model).toBe("anthropic/claude-opus-4-6")
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
test("does not set needsWrite when no model versions need migration", () => {
|
|
|
|
|
// given: Config with current model versions
|
|
|
|
|
const rawConfig: Record<string, unknown> = {
|
|
|
|
|
agents: {
|
2026-03-07 02:33:32 +09:00
|
|
|
sisyphus: { model: "openai/gpt-5.4-codex" },
|
2026-02-06 15:55:28 +09:00
|
|
|
},
|
|
|
|
|
categories: {
|
|
|
|
|
"my-category": { model: "anthropic/claude-opus-4-6" },
|
|
|
|
|
},
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// when: Migrate config file
|
|
|
|
|
const needsWrite = migrateConfigFile(testConfigPath, rawConfig)
|
|
|
|
|
|
|
|
|
|
// then: No write should be needed
|
|
|
|
|
expect(needsWrite).toBe(false)
|
|
|
|
|
})
|
2025-12-31 12:41:01 +09:00
|
|
|
})
|
|
|
|
|
|
|
|
|
|
describe("migration maps", () => {
|
|
|
|
|
test("AGENT_NAME_MAP contains all expected legacy mappings", () => {
|
2026-02-01 16:47:50 +09:00
|
|
|
// given/#when: Check AGENT_NAME_MAP
|
|
|
|
|
// then: Should contain all legacy → lowercase mappings
|
2026-01-23 19:01:10 +09:00
|
|
|
expect(AGENT_NAME_MAP["omo"]).toBe("sisyphus")
|
|
|
|
|
expect(AGENT_NAME_MAP["OmO"]).toBe("sisyphus")
|
|
|
|
|
expect(AGENT_NAME_MAP["OmO-Plan"]).toBe("prometheus")
|
|
|
|
|
expect(AGENT_NAME_MAP["omo-plan"]).toBe("prometheus")
|
|
|
|
|
expect(AGENT_NAME_MAP["Planner-Sisyphus"]).toBe("prometheus")
|
|
|
|
|
expect(AGENT_NAME_MAP["plan-consultant"]).toBe("metis")
|
2025-12-31 12:41:01 +09:00
|
|
|
})
|
|
|
|
|
|
|
|
|
|
test("HOOK_NAME_MAP contains anthropic-auto-compact migration", () => {
|
2026-02-01 16:47:50 +09:00
|
|
|
// given/#when: Check HOOK_NAME_MAP
|
|
|
|
|
// then: Should contain be legacy hook name mapping
|
2025-12-31 12:41:01 +09:00
|
|
|
expect(HOOK_NAME_MAP["anthropic-auto-compact"]).toBe("anthropic-context-window-limit-recovery")
|
|
|
|
|
})
|
|
|
|
|
})
|
2026-01-09 02:24:43 +09:00
|
|
|
|
2026-02-06 15:55:28 +09:00
|
|
|
describe("MODEL_VERSION_MAP", () => {
|
2026-03-07 02:33:32 +09:00
|
|
|
test("does not include openai/gpt-5.4-codex migration", () => {
|
2026-02-06 15:55:28 +09:00
|
|
|
// given/when: Check MODEL_VERSION_MAP
|
2026-03-07 02:33:32 +09:00
|
|
|
// then: openai/gpt-5.4-codex should not be migrated
|
|
|
|
|
expect(MODEL_VERSION_MAP["openai/gpt-5.4-codex"]).toBeUndefined()
|
2026-02-06 15:55:28 +09:00
|
|
|
})
|
|
|
|
|
|
|
|
|
|
test("maps anthropic/claude-opus-4-5 to anthropic/claude-opus-4-6", () => {
|
|
|
|
|
// given/when: Check MODEL_VERSION_MAP
|
|
|
|
|
// then: Should contain correct mapping
|
|
|
|
|
expect(MODEL_VERSION_MAP["anthropic/claude-opus-4-5"]).toBe("anthropic/claude-opus-4-6")
|
|
|
|
|
})
|
2026-03-31 17:26:30 -07:00
|
|
|
|
|
|
|
|
test("maps openai/gpt-5.3-codex to openai/gpt-5.4 for deep category migration", () => {
|
|
|
|
|
// given/when: Check MODEL_VERSION_MAP
|
|
|
|
|
// then: gpt-5.3-codex should migrate to gpt-5.4
|
|
|
|
|
expect(MODEL_VERSION_MAP["openai/gpt-5.3-codex"]).toBe("openai/gpt-5.4")
|
|
|
|
|
})
|
2026-02-06 15:55:28 +09:00
|
|
|
})
|
|
|
|
|
|
|
|
|
|
describe("migrateModelVersions", () => {
|
2026-03-07 02:33:32 +09:00
|
|
|
test("#given a config with gpt-5.4-codex model #when migrating model versions #then does not overwrite with non-existent gpt-5.3-codex", () => {
|
|
|
|
|
// given: Agent config with gpt-5.4-codex model
|
2026-02-06 15:55:28 +09:00
|
|
|
const agents = {
|
2026-03-07 02:33:32 +09:00
|
|
|
sisyphus: { model: "openai/gpt-5.4-codex", temperature: 0.1 },
|
2026-02-06 15:55:28 +09:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// when: Migrate model versions
|
|
|
|
|
const { migrated, changed } = migrateModelVersions(agents)
|
|
|
|
|
|
2026-02-21 02:22:45 +09:00
|
|
|
// then: Model should remain unchanged
|
|
|
|
|
expect(changed).toBe(false)
|
2026-02-06 15:55:28 +09:00
|
|
|
const sisyphus = migrated["sisyphus"] as Record<string, unknown>
|
2026-03-07 02:33:32 +09:00
|
|
|
expect(sisyphus.model).toBe("openai/gpt-5.4-codex")
|
2026-02-06 15:55:28 +09:00
|
|
|
expect(sisyphus.temperature).toBe(0.1)
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
test("replaces anthropic model version", () => {
|
|
|
|
|
// given: Agent config with old anthropic model
|
|
|
|
|
const agents = {
|
|
|
|
|
prometheus: { model: "anthropic/claude-opus-4-5" },
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// when: Migrate model versions
|
|
|
|
|
const { migrated, changed } = migrateModelVersions(agents)
|
|
|
|
|
|
|
|
|
|
// then: Model should be updated
|
|
|
|
|
expect(changed).toBe(true)
|
|
|
|
|
const prometheus = migrated["prometheus"] as Record<string, unknown>
|
|
|
|
|
expect(prometheus.model).toBe("anthropic/claude-opus-4-6")
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
test("leaves unknown model strings untouched", () => {
|
|
|
|
|
// given: Agent config with unknown model
|
|
|
|
|
const agents = {
|
2026-03-07 02:33:32 +09:00
|
|
|
oracle: { model: "openai/gpt-5.4", temperature: 0.5 },
|
2026-02-06 15:55:28 +09:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// when: Migrate model versions
|
|
|
|
|
const { migrated, changed } = migrateModelVersions(agents)
|
|
|
|
|
|
|
|
|
|
// then: Config should remain unchanged
|
|
|
|
|
expect(changed).toBe(false)
|
|
|
|
|
const oracle = migrated["oracle"] as Record<string, unknown>
|
2026-03-07 02:33:32 +09:00
|
|
|
expect(oracle.model).toBe("openai/gpt-5.4")
|
2026-02-06 15:55:28 +09:00
|
|
|
})
|
|
|
|
|
|
|
|
|
|
test("handles agent config with no model field", () => {
|
|
|
|
|
// given: Agent config without model field
|
|
|
|
|
const agents = {
|
|
|
|
|
sisyphus: { temperature: 0.1, prompt: "custom" },
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// when: Migrate model versions
|
|
|
|
|
const { migrated, changed } = migrateModelVersions(agents)
|
|
|
|
|
|
|
|
|
|
// then: Config should remain unchanged
|
|
|
|
|
expect(changed).toBe(false)
|
|
|
|
|
const sisyphus = migrated["sisyphus"] as Record<string, unknown>
|
|
|
|
|
expect(sisyphus.temperature).toBe(0.1)
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
test("handles agent config with non-string model", () => {
|
|
|
|
|
// given: Agent config with non-string model
|
|
|
|
|
const agents = {
|
|
|
|
|
sisyphus: { model: 123, temperature: 0.1 },
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// when: Migrate model versions
|
|
|
|
|
const { migrated, changed } = migrateModelVersions(agents)
|
|
|
|
|
|
|
|
|
|
// then: Config should remain unchanged
|
|
|
|
|
expect(changed).toBe(false)
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
test("migrates multiple agents in one pass", () => {
|
|
|
|
|
// given: Multiple agents with old models
|
|
|
|
|
const agents = {
|
2026-03-07 02:33:32 +09:00
|
|
|
sisyphus: { model: "openai/gpt-5.4-codex" },
|
2026-02-06 15:55:28 +09:00
|
|
|
prometheus: { model: "anthropic/claude-opus-4-5" },
|
2026-03-07 02:33:32 +09:00
|
|
|
oracle: { model: "openai/gpt-5.4" },
|
2026-02-06 15:55:28 +09:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// when: Migrate model versions
|
|
|
|
|
const { migrated, changed } = migrateModelVersions(agents)
|
|
|
|
|
|
|
|
|
|
// then: Only mapped models should be updated
|
|
|
|
|
expect(changed).toBe(true)
|
2026-03-07 02:33:32 +09:00
|
|
|
expect((migrated["sisyphus"] as Record<string, unknown>).model).toBe("openai/gpt-5.4-codex")
|
2026-02-06 15:55:28 +09:00
|
|
|
expect((migrated["prometheus"] as Record<string, unknown>).model).toBe("anthropic/claude-opus-4-6")
|
2026-03-07 02:33:32 +09:00
|
|
|
expect((migrated["oracle"] as Record<string, unknown>).model).toBe("openai/gpt-5.4")
|
2026-02-06 15:55:28 +09:00
|
|
|
})
|
|
|
|
|
|
|
|
|
|
test("handles empty object", () => {
|
|
|
|
|
// given: Empty agents object
|
|
|
|
|
const agents = {}
|
|
|
|
|
|
|
|
|
|
// when: Migrate model versions
|
|
|
|
|
const { migrated, changed } = migrateModelVersions(agents)
|
|
|
|
|
|
|
|
|
|
// then: Should return empty with no change
|
|
|
|
|
expect(changed).toBe(false)
|
|
|
|
|
expect(Object.keys(migrated)).toHaveLength(0)
|
|
|
|
|
})
|
2026-02-07 18:25:23 +09:00
|
|
|
|
|
|
|
|
test("skips already-applied migrations", () => {
|
|
|
|
|
// given: Agent config with old model, but migration already applied
|
|
|
|
|
const agents = {
|
2026-03-07 02:33:32 +09:00
|
|
|
sisyphus: { model: "openai/gpt-5.4-codex", temperature: 0.1 },
|
2026-02-07 18:25:23 +09:00
|
|
|
}
|
2026-03-07 02:33:32 +09:00
|
|
|
const appliedMigrations = new Set(["model-version:openai/gpt-5.4-codex->openai/gpt-5.3-codex"])
|
2026-02-07 18:25:23 +09:00
|
|
|
|
|
|
|
|
// when: Migrate with applied migrations
|
|
|
|
|
const { migrated, changed, newMigrations } = migrateModelVersions(agents, appliedMigrations)
|
|
|
|
|
|
|
|
|
|
// then: Model should NOT be changed (user reverted intentionally)
|
|
|
|
|
expect(changed).toBe(false)
|
|
|
|
|
expect(newMigrations).toHaveLength(0)
|
|
|
|
|
const sisyphus = migrated["sisyphus"] as Record<string, unknown>
|
2026-03-07 02:33:32 +09:00
|
|
|
expect(sisyphus.model).toBe("openai/gpt-5.4-codex")
|
2026-02-07 18:25:23 +09:00
|
|
|
})
|
|
|
|
|
|
|
|
|
|
test("applies new migrations and records them", () => {
|
|
|
|
|
// given: Agent config with old model, no prior migrations
|
|
|
|
|
const agents = {
|
2026-03-07 02:33:32 +09:00
|
|
|
sisyphus: { model: "openai/gpt-5.4-codex" },
|
2026-02-07 18:25:23 +09:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// when: Migrate without applied migrations
|
|
|
|
|
const { migrated, changed, newMigrations } = migrateModelVersions(agents)
|
|
|
|
|
|
2026-03-07 02:33:32 +09:00
|
|
|
// then: No migration should be applied for gpt-5.4-codex
|
2026-02-21 02:22:45 +09:00
|
|
|
expect(changed).toBe(false)
|
|
|
|
|
expect(newMigrations).toEqual([])
|
2026-02-07 18:25:23 +09:00
|
|
|
const sisyphus = migrated["sisyphus"] as Record<string, unknown>
|
2026-03-07 02:33:32 +09:00
|
|
|
expect(sisyphus.model).toBe("openai/gpt-5.4-codex")
|
2026-02-07 18:25:23 +09:00
|
|
|
})
|
|
|
|
|
|
|
|
|
|
test("handles mixed: some applied, some new", () => {
|
|
|
|
|
// given: Multiple agents, one migration already applied
|
|
|
|
|
const agents = {
|
2026-03-07 02:33:32 +09:00
|
|
|
sisyphus: { model: "openai/gpt-5.4-codex" },
|
2026-02-07 18:25:23 +09:00
|
|
|
prometheus: { model: "anthropic/claude-opus-4-5" },
|
|
|
|
|
}
|
2026-03-07 02:33:32 +09:00
|
|
|
const appliedMigrations = new Set(["model-version:openai/gpt-5.4-codex->openai/gpt-5.3-codex"])
|
2026-02-07 18:25:23 +09:00
|
|
|
|
|
|
|
|
// when: Migrate with partial history
|
|
|
|
|
const { migrated, changed, newMigrations } = migrateModelVersions(agents, appliedMigrations)
|
|
|
|
|
|
|
|
|
|
// then: Only prometheus should be migrated
|
|
|
|
|
expect(changed).toBe(true)
|
|
|
|
|
expect(newMigrations).toEqual(["model-version:anthropic/claude-opus-4-5->anthropic/claude-opus-4-6"])
|
2026-03-07 02:33:32 +09:00
|
|
|
expect((migrated["sisyphus"] as Record<string, unknown>).model).toBe("openai/gpt-5.4-codex")
|
2026-02-07 18:25:23 +09:00
|
|
|
expect((migrated["prometheus"] as Record<string, unknown>).model).toBe("anthropic/claude-opus-4-6")
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
test("backward compatible without appliedMigrations param", () => {
|
|
|
|
|
// given: Agent config with old model, no appliedMigrations param
|
|
|
|
|
const agents = {
|
2026-03-07 02:33:32 +09:00
|
|
|
sisyphus: { model: "openai/gpt-5.4-codex" },
|
2026-02-07 18:25:23 +09:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// when: Migrate without the param (backward compat)
|
|
|
|
|
const { migrated, changed, newMigrations } = migrateModelVersions(agents)
|
|
|
|
|
|
2026-03-07 02:33:32 +09:00
|
|
|
// then: Should keep gpt-5.4-codex unchanged
|
2026-02-21 02:22:45 +09:00
|
|
|
expect(changed).toBe(false)
|
|
|
|
|
expect(newMigrations).toHaveLength(0)
|
2026-03-07 02:33:32 +09:00
|
|
|
expect((migrated["sisyphus"] as Record<string, unknown>).model).toBe("openai/gpt-5.4-codex")
|
2026-02-07 18:25:23 +09:00
|
|
|
})
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
describe("migrateConfigFile _migrations tracking", () => {
|
|
|
|
|
test("records migrations in _migrations field", () => {
|
|
|
|
|
// given: Config with old model, no prior migrations
|
|
|
|
|
const tmpDir = fs.mkdtempSync("/tmp/migration-test-")
|
|
|
|
|
const configPath = `${tmpDir}/oh-my-opencode.json`
|
|
|
|
|
const rawConfig: Record<string, unknown> = {
|
|
|
|
|
agents: {
|
2026-03-07 02:33:32 +09:00
|
|
|
sisyphus: { model: "openai/gpt-5.4-codex" },
|
2026-02-07 18:25:23 +09:00
|
|
|
},
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// when: Migrate config file
|
|
|
|
|
const result = migrateConfigFile(configPath, rawConfig)
|
|
|
|
|
|
2026-03-07 02:33:32 +09:00
|
|
|
// then: gpt-5.4-codex should not produce migrations
|
2026-02-21 02:22:45 +09:00
|
|
|
expect(result).toBe(false)
|
|
|
|
|
expect(rawConfig._migrations).toBeUndefined()
|
2026-02-07 18:25:23 +09:00
|
|
|
|
|
|
|
|
// cleanup
|
|
|
|
|
fs.rmSync(tmpDir, { recursive: true })
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
test("skips re-migration when _migrations contains the key", () => {
|
|
|
|
|
// given: Config with old model BUT migration already recorded
|
|
|
|
|
const tmpDir = fs.mkdtempSync("/tmp/migration-test-")
|
|
|
|
|
const configPath = `${tmpDir}/oh-my-opencode.json`
|
|
|
|
|
const rawConfig: Record<string, unknown> = {
|
|
|
|
|
agents: {
|
2026-03-07 02:33:32 +09:00
|
|
|
sisyphus: { model: "openai/gpt-5.4-codex" },
|
2026-02-07 18:25:23 +09:00
|
|
|
},
|
2026-03-07 02:33:32 +09:00
|
|
|
_migrations: ["model-version:openai/gpt-5.4-codex->openai/gpt-5.3-codex"],
|
2026-02-07 18:25:23 +09:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// when: Migrate config file
|
|
|
|
|
const result = migrateConfigFile(configPath, rawConfig)
|
|
|
|
|
|
|
|
|
|
// then: Should NOT rewrite (model stays as user set it)
|
|
|
|
|
// Note: result may be true due to other migrations, but model should NOT change
|
|
|
|
|
const sisyphus = (rawConfig.agents as Record<string, Record<string, unknown>>).sisyphus
|
2026-03-07 02:33:32 +09:00
|
|
|
expect(sisyphus.model).toBe("openai/gpt-5.4-codex")
|
2026-02-07 18:25:23 +09:00
|
|
|
|
|
|
|
|
// cleanup
|
|
|
|
|
fs.rmSync(tmpDir, { recursive: true })
|
|
|
|
|
})
|
|
|
|
|
|
2026-04-09 10:54:08 +09:00
|
|
|
test("migrates legacy in-config _migrations into the sidecar and appends new migrations (#3263)", () => {
|
|
|
|
|
// given: Config with an existing legacy in-config _migrations history and a new migratable model
|
2026-02-07 18:25:23 +09:00
|
|
|
const tmpDir = fs.mkdtempSync("/tmp/migration-test-")
|
|
|
|
|
const configPath = `${tmpDir}/oh-my-opencode.json`
|
|
|
|
|
const rawConfig: Record<string, unknown> = {
|
|
|
|
|
agents: {
|
|
|
|
|
prometheus: { model: "anthropic/claude-opus-4-5" },
|
|
|
|
|
},
|
2026-03-07 02:33:32 +09:00
|
|
|
_migrations: ["model-version:openai/gpt-5.4-codex->openai/gpt-5.3-codex"],
|
2026-02-07 18:25:23 +09:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// when: Migrate config file
|
|
|
|
|
const result = migrateConfigFile(configPath, rawConfig)
|
|
|
|
|
|
2026-04-09 10:54:08 +09:00
|
|
|
// then: The config body has _migrations stripped. The full history
|
|
|
|
|
// (legacy + new) is written to the sidecar file exactly once.
|
2026-02-07 18:25:23 +09:00
|
|
|
expect(result).toBe(true)
|
2026-04-09 10:54:08 +09:00
|
|
|
expect(rawConfig._migrations).toBeUndefined()
|
|
|
|
|
expect((rawConfig.agents as Record<string, Record<string, unknown>>).prometheus.model).toBe("anthropic/claude-opus-4-6")
|
|
|
|
|
|
|
|
|
|
const sidecar = JSON.parse(fs.readFileSync(`${configPath}.migrations.json`, "utf-8"))
|
|
|
|
|
expect(new Set(sidecar.appliedMigrations)).toEqual(new Set([
|
2026-03-07 02:33:32 +09:00
|
|
|
"model-version:openai/gpt-5.4-codex->openai/gpt-5.3-codex",
|
2026-02-07 18:25:23 +09:00
|
|
|
"model-version:anthropic/claude-opus-4-5->anthropic/claude-opus-4-6",
|
2026-04-09 10:54:08 +09:00
|
|
|
]))
|
2026-02-07 18:25:23 +09:00
|
|
|
|
|
|
|
|
// cleanup
|
|
|
|
|
fs.rmSync(tmpDir, { recursive: true })
|
|
|
|
|
})
|
2026-02-06 15:55:28 +09:00
|
|
|
})
|
|
|
|
|
|
2026-01-09 02:24:43 +09:00
|
|
|
describe("migrateAgentConfigToCategory", () => {
|
|
|
|
|
test("migrates model to category when mapping exists", () => {
|
2026-02-01 16:47:50 +09:00
|
|
|
// given: Config with a model that has a category mapping
|
2026-01-09 02:24:43 +09:00
|
|
|
const config = {
|
2026-02-26 21:01:05 +09:00
|
|
|
model: "google/gemini-3.1-pro",
|
2026-01-09 02:24:43 +09:00
|
|
|
temperature: 0.5,
|
|
|
|
|
top_p: 0.9,
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-01 16:47:50 +09:00
|
|
|
// when: Migrate agent config to category
|
2026-01-09 02:24:43 +09:00
|
|
|
const { migrated, changed } = migrateAgentConfigToCategory(config)
|
|
|
|
|
|
2026-02-01 16:47:50 +09:00
|
|
|
// then: Model should be replaced with category
|
2026-01-09 02:24:43 +09:00
|
|
|
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", () => {
|
2026-02-01 16:47:50 +09:00
|
|
|
// given: Config with a model that has no mapping
|
2026-01-09 02:24:43 +09:00
|
|
|
const config = {
|
|
|
|
|
model: "custom/model",
|
|
|
|
|
temperature: 0.5,
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-01 16:47:50 +09:00
|
|
|
// when: Migrate agent config to category
|
2026-01-09 02:24:43 +09:00
|
|
|
const { migrated, changed } = migrateAgentConfigToCategory(config)
|
|
|
|
|
|
2026-02-01 16:47:50 +09:00
|
|
|
// then: Config should remain unchanged
|
2026-01-09 02:24:43 +09:00
|
|
|
expect(changed).toBe(false)
|
|
|
|
|
expect(migrated).toEqual(config)
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
test("does not migrate when model is not a string", () => {
|
2026-02-01 16:47:50 +09:00
|
|
|
// given: Config with non-string model
|
2026-01-09 02:24:43 +09:00
|
|
|
const config = {
|
|
|
|
|
model: { name: "test" },
|
|
|
|
|
temperature: 0.5,
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-01 16:47:50 +09:00
|
|
|
// when: Migrate agent config to category
|
2026-01-09 02:24:43 +09:00
|
|
|
const { migrated, changed } = migrateAgentConfigToCategory(config)
|
|
|
|
|
|
2026-02-01 16:47:50 +09:00
|
|
|
// then: Config should remain unchanged
|
2026-01-09 02:24:43 +09:00
|
|
|
expect(changed).toBe(false)
|
|
|
|
|
expect(migrated).toEqual(config)
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
test("handles all mapped models correctly", () => {
|
2026-02-01 16:47:50 +09:00
|
|
|
// given: Configs for each mapped model
|
2026-01-09 02:24:43 +09:00
|
|
|
const configs = [
|
2026-02-26 21:01:05 +09:00
|
|
|
{ model: "google/gemini-3.1-pro" },
|
2026-01-24 17:05:14 +09:00
|
|
|
{ model: "google/gemini-3-flash" },
|
2026-03-07 02:33:32 +09:00
|
|
|
{ model: "openai/gpt-5.4" },
|
2026-01-09 02:24:43 +09:00
|
|
|
{ model: "anthropic/claude-haiku-4-5" },
|
2026-02-06 14:21:15 +09:00
|
|
|
{ model: "anthropic/claude-opus-4-6" },
|
2026-02-18 15:51:24 +09:00
|
|
|
{ model: "anthropic/claude-sonnet-4-6" },
|
2026-01-09 02:24:43 +09:00
|
|
|
]
|
|
|
|
|
|
2026-01-24 17:05:14 +09:00
|
|
|
const expectedCategories = ["visual-engineering", "writing", "ultrabrain", "quick", "unspecified-high", "unspecified-low"]
|
2026-01-09 02:24:43 +09:00
|
|
|
|
2026-02-01 16:47:50 +09:00
|
|
|
// when: Migrate each config
|
2026-01-09 02:24:43 +09:00
|
|
|
const results = configs.map(migrateAgentConfigToCategory)
|
|
|
|
|
|
2026-02-01 16:47:50 +09:00
|
|
|
// then: Each model should map to correct category
|
2026-01-09 02:24:43 +09:00
|
|
|
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", () => {
|
2026-02-01 16:47:50 +09:00
|
|
|
// given: Config with multiple fields
|
2026-01-09 02:24:43 +09:00
|
|
|
const config = {
|
2026-03-07 02:33:32 +09:00
|
|
|
model: "openai/gpt-5.4",
|
2026-01-09 02:24:43 +09:00
|
|
|
temperature: 0.1,
|
|
|
|
|
top_p: 0.95,
|
|
|
|
|
maxTokens: 4096,
|
|
|
|
|
prompt_append: "custom instruction",
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-01 16:47:50 +09:00
|
|
|
// when: Migrate agent config to category
|
2026-01-09 02:24:43 +09:00
|
|
|
const { migrated } = migrateAgentConfigToCategory(config)
|
|
|
|
|
|
2026-02-01 16:47:50 +09:00
|
|
|
// then: All non-model fields should be preserved
|
2026-01-09 02:24:43 +09:00
|
|
|
expect(migrated.category).toBe("ultrabrain")
|
|
|
|
|
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", () => {
|
2026-02-01 16:47:50 +09:00
|
|
|
// given: Config with only category field (no overrides)
|
2026-01-09 02:24:43 +09:00
|
|
|
const config = { category: "visual-engineering" }
|
|
|
|
|
|
2026-02-01 16:47:50 +09:00
|
|
|
// when: Check if config should be deleted
|
2026-01-09 02:24:43 +09:00
|
|
|
const shouldDelete = shouldDeleteAgentConfig(config, "visual-engineering")
|
|
|
|
|
|
2026-02-01 16:47:50 +09:00
|
|
|
// then: Should return true (matches category defaults)
|
2026-01-09 02:24:43 +09:00
|
|
|
expect(shouldDelete).toBe(true)
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
test("returns false when category does not exist", () => {
|
2026-02-01 16:47:50 +09:00
|
|
|
// given: Config with unknown category
|
2026-01-09 02:24:43 +09:00
|
|
|
const config = { category: "unknown" }
|
|
|
|
|
|
2026-02-01 16:47:50 +09:00
|
|
|
// when: Check if config should be deleted
|
2026-01-09 02:24:43 +09:00
|
|
|
const shouldDelete = shouldDeleteAgentConfig(config, "unknown")
|
|
|
|
|
|
2026-02-01 16:47:50 +09:00
|
|
|
// then: Should return false (category not found)
|
2026-01-09 02:24:43 +09:00
|
|
|
expect(shouldDelete).toBe(false)
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
test("returns true when all fields match category defaults", () => {
|
2026-02-01 16:47:50 +09:00
|
|
|
// given: Config with fields matching category defaults
|
2026-01-09 02:24:43 +09:00
|
|
|
const config = {
|
|
|
|
|
category: "visual-engineering",
|
2026-02-26 21:01:05 +09:00
|
|
|
model: "google/gemini-3.1-pro",
|
2026-01-09 02:24:43 +09:00
|
|
|
}
|
|
|
|
|
|
2026-02-01 16:47:50 +09:00
|
|
|
// when: Check if config should be deleted
|
2026-01-09 02:24:43 +09:00
|
|
|
const shouldDelete = shouldDeleteAgentConfig(config, "visual-engineering")
|
|
|
|
|
|
2026-02-01 16:47:50 +09:00
|
|
|
// then: Should return true (all fields match defaults)
|
2026-01-09 02:24:43 +09:00
|
|
|
expect(shouldDelete).toBe(true)
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
test("returns false when fields differ from category defaults", () => {
|
2026-02-01 16:47:50 +09:00
|
|
|
// given: Config with custom model override
|
2026-01-09 02:24:43 +09:00
|
|
|
const config = {
|
|
|
|
|
category: "visual-engineering",
|
2026-02-06 14:21:15 +09:00
|
|
|
model: "anthropic/claude-opus-4-6",
|
2026-01-09 02:24:43 +09:00
|
|
|
}
|
|
|
|
|
|
2026-02-01 16:47:50 +09:00
|
|
|
// when: Check if config should be deleted
|
2026-01-09 02:24:43 +09:00
|
|
|
const shouldDelete = shouldDeleteAgentConfig(config, "visual-engineering")
|
|
|
|
|
|
2026-02-01 16:47:50 +09:00
|
|
|
// then: Should return false (has custom override)
|
2026-01-09 02:24:43 +09:00
|
|
|
expect(shouldDelete).toBe(false)
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
test("handles different categories with their defaults", () => {
|
2026-02-01 16:47:50 +09:00
|
|
|
// given: Configs for different categories
|
2026-01-09 02:24:43 +09:00
|
|
|
const configs = [
|
2026-01-20 16:22:53 +09:00
|
|
|
{ category: "ultrabrain" },
|
|
|
|
|
{ category: "quick" },
|
|
|
|
|
{ category: "unspecified-high" },
|
|
|
|
|
{ category: "unspecified-low" },
|
2026-01-09 02:24:43 +09:00
|
|
|
]
|
|
|
|
|
|
2026-02-01 16:47:50 +09:00
|
|
|
// when: Check each config
|
2026-01-09 02:24:43 +09:00
|
|
|
const results = configs.map((config) => shouldDeleteAgentConfig(config, config.category as string))
|
|
|
|
|
|
2026-02-01 16:47:50 +09:00
|
|
|
// then: All should be true (all match defaults)
|
2026-01-09 02:24:43 +09:00
|
|
|
results.forEach((result) => {
|
|
|
|
|
expect(result).toBe(true)
|
|
|
|
|
})
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
test("returns false when additional fields are present", () => {
|
2026-02-01 16:47:50 +09:00
|
|
|
// given: Config with extra fields
|
2026-01-09 02:24:43 +09:00
|
|
|
const config = {
|
|
|
|
|
category: "visual-engineering",
|
|
|
|
|
temperature: 0.7,
|
|
|
|
|
custom_field: "value", // Extra field not in defaults
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-01 16:47:50 +09:00
|
|
|
// when: Check if config should be deleted
|
2026-01-09 02:24:43 +09:00
|
|
|
const shouldDelete = shouldDeleteAgentConfig(config, "visual-engineering")
|
|
|
|
|
|
2026-02-01 16:47:50 +09:00
|
|
|
// then: Should return false (has extra field)
|
2026-01-09 02:24:43 +09:00
|
|
|
expect(shouldDelete).toBe(false)
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
test("handles complex config with multiple overrides", () => {
|
2026-02-01 16:47:50 +09:00
|
|
|
// given: Config with multiple custom overrides
|
2026-01-09 02:24:43 +09:00
|
|
|
const config = {
|
|
|
|
|
category: "visual-engineering",
|
|
|
|
|
temperature: 0.5, // Different from default
|
|
|
|
|
top_p: 0.8, // Different from default
|
|
|
|
|
prompt_append: "custom prompt", // Custom field
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-01 16:47:50 +09:00
|
|
|
// when: Check if config should be deleted
|
2026-01-09 02:24:43 +09:00
|
|
|
const shouldDelete = shouldDeleteAgentConfig(config, "visual-engineering")
|
|
|
|
|
|
2026-02-01 16:47:50 +09:00
|
|
|
// then: Should return false (has overrides)
|
2026-01-09 02:24:43 +09:00
|
|
|
expect(shouldDelete).toBe(false)
|
|
|
|
|
})
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
describe("migrateConfigFile with backup", () => {
|
|
|
|
|
const cleanupPaths: string[] = []
|
|
|
|
|
|
|
|
|
|
afterEach(() => {
|
|
|
|
|
cleanupPaths.forEach((p) => {
|
|
|
|
|
try {
|
|
|
|
|
fs.unlinkSync(p)
|
|
|
|
|
} catch {
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
})
|
|
|
|
|
|
2026-01-14 11:57:08 +09:00
|
|
|
test("creates backup file with timestamp when legacy migration needed", () => {
|
2026-02-01 16:47:50 +09:00
|
|
|
// given: Config file path with legacy agent names needing migration
|
2026-01-09 02:24:43 +09:00
|
|
|
const testConfigPath = "/tmp/test-config-migration.json"
|
2026-01-14 11:57:08 +09:00
|
|
|
const testConfigContent = globalThis.JSON.stringify({ agents: { omo: { model: "test" } } }, null, 2)
|
2026-01-09 02:24:43 +09:00
|
|
|
const rawConfig: Record<string, unknown> = {
|
|
|
|
|
agents: {
|
2026-01-14 11:57:08 +09:00
|
|
|
omo: { model: "test" },
|
2026-01-09 02:24:43 +09:00
|
|
|
},
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fs.writeFileSync(testConfigPath, testConfigContent)
|
|
|
|
|
cleanupPaths.push(testConfigPath)
|
|
|
|
|
|
2026-02-01 16:47:50 +09:00
|
|
|
// when: Migrate config file
|
2026-01-09 02:24:43 +09:00
|
|
|
const needsWrite = migrateConfigFile(testConfigPath, rawConfig)
|
|
|
|
|
|
2026-02-01 16:47:50 +09:00
|
|
|
// then: Backup file should be created with timestamp
|
2026-01-09 02:24:43 +09:00
|
|
|
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)
|
|
|
|
|
})
|
|
|
|
|
|
2026-01-14 11:57:08 +09:00
|
|
|
test("preserves model setting without auto-conversion to category", () => {
|
2026-02-01 16:47:50 +09:00
|
|
|
// given: Config with model setting (should NOT be converted to category)
|
2026-01-14 11:57:08 +09:00
|
|
|
const testConfigPath = "/tmp/test-config-preserve-model.json"
|
2026-01-09 02:24:43 +09:00
|
|
|
const rawConfig: Record<string, unknown> = {
|
|
|
|
|
agents: {
|
2026-01-14 11:57:08 +09:00
|
|
|
"multimodal-looker": { model: "anthropic/claude-haiku-4-5" },
|
2026-03-07 02:33:32 +09:00
|
|
|
oracle: { model: "openai/gpt-5.4" },
|
2026-02-26 21:01:05 +09:00
|
|
|
"my-custom-agent": { model: "google/gemini-3.1-pro" },
|
2026-01-09 02:24:43 +09:00
|
|
|
},
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-14 11:57:08 +09:00
|
|
|
fs.writeFileSync(testConfigPath, globalThis.JSON.stringify(rawConfig, null, 2))
|
2026-01-09 02:24:43 +09:00
|
|
|
cleanupPaths.push(testConfigPath)
|
|
|
|
|
|
2026-02-01 16:47:50 +09:00
|
|
|
// when: Migrate config file
|
2026-01-09 02:24:43 +09:00
|
|
|
const needsWrite = migrateConfigFile(testConfigPath, rawConfig)
|
|
|
|
|
|
2026-02-01 16:47:50 +09:00
|
|
|
// then: No migration needed - model settings should be preserved as-is
|
2026-01-14 11:57:08 +09:00
|
|
|
expect(needsWrite).toBe(false)
|
2026-01-09 02:24:43 +09:00
|
|
|
|
2026-01-14 11:57:08 +09:00
|
|
|
const agents = rawConfig.agents as Record<string, Record<string, unknown>>
|
|
|
|
|
expect(agents["multimodal-looker"].model).toBe("anthropic/claude-haiku-4-5")
|
2026-03-07 02:33:32 +09:00
|
|
|
expect(agents.oracle.model).toBe("openai/gpt-5.4")
|
2026-02-26 21:01:05 +09:00
|
|
|
expect(agents["my-custom-agent"].model).toBe("google/gemini-3.1-pro")
|
2026-01-09 02:24:43 +09:00
|
|
|
})
|
|
|
|
|
|
2026-01-14 11:57:08 +09:00
|
|
|
test("preserves category setting when explicitly set", () => {
|
2026-02-01 16:47:50 +09:00
|
|
|
// given: Config with explicit category setting
|
2026-01-14 11:57:08 +09:00
|
|
|
const testConfigPath = "/tmp/test-config-preserve-category.json"
|
2026-01-09 02:24:43 +09:00
|
|
|
const rawConfig: Record<string, unknown> = {
|
|
|
|
|
agents: {
|
2026-01-14 11:57:08 +09:00
|
|
|
"multimodal-looker": { category: "quick" },
|
|
|
|
|
oracle: { category: "ultrabrain" },
|
2026-01-09 02:24:43 +09:00
|
|
|
},
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-14 11:57:08 +09:00
|
|
|
fs.writeFileSync(testConfigPath, globalThis.JSON.stringify(rawConfig, null, 2))
|
2026-01-09 02:24:43 +09:00
|
|
|
cleanupPaths.push(testConfigPath)
|
|
|
|
|
|
2026-02-01 16:47:50 +09:00
|
|
|
// when: Migrate config file
|
2026-01-09 02:24:43 +09:00
|
|
|
const needsWrite = migrateConfigFile(testConfigPath, rawConfig)
|
|
|
|
|
|
2026-02-01 16:47:50 +09:00
|
|
|
// then: No migration needed - category settings should be preserved as-is
|
2026-01-14 11:57:08 +09:00
|
|
|
expect(needsWrite).toBe(false)
|
2026-01-09 02:24:43 +09:00
|
|
|
|
2026-01-14 11:57:08 +09:00
|
|
|
const agents = rawConfig.agents as Record<string, Record<string, unknown>>
|
|
|
|
|
expect(agents["multimodal-looker"].category).toBe("quick")
|
|
|
|
|
expect(agents.oracle.category).toBe("ultrabrain")
|
2026-01-09 02:24:43 +09:00
|
|
|
})
|
|
|
|
|
|
2026-02-07 19:51:22 +09:00
|
|
|
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<string, unknown> = {
|
|
|
|
|
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)
|
|
|
|
|
})
|
|
|
|
|
|
2026-01-09 02:24:43 +09:00
|
|
|
test("does not write when no migration needed", () => {
|
2026-02-01 16:47:50 +09:00
|
|
|
// given: Config with no migrations needed
|
2026-01-23 19:01:10 +09:00
|
|
|
const testConfigPath = "/tmp/test-config-no-migration.json"
|
|
|
|
|
const rawConfig: Record<string, unknown> = {
|
|
|
|
|
agents: {
|
|
|
|
|
sisyphus: { model: "test" },
|
|
|
|
|
},
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fs.writeFileSync(testConfigPath, globalThis.JSON.stringify({ agents: { sisyphus: { model: "test" } } }, null, 2))
|
|
|
|
|
cleanupPaths.push(testConfigPath)
|
|
|
|
|
|
|
|
|
|
// Clean up any existing backup files from previous test runs
|
|
|
|
|
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 {
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
|
2026-02-01 16:47:50 +09:00
|
|
|
// when: Migrate config file
|
2026-01-23 19:01:10 +09:00
|
|
|
const needsWrite = migrateConfigFile(testConfigPath, rawConfig)
|
|
|
|
|
|
2026-02-01 16:47:50 +09:00
|
|
|
// then: Should not write or create backup
|
2026-01-23 19:01:10 +09:00
|
|
|
expect(needsWrite).toBe(false)
|
|
|
|
|
|
|
|
|
|
const files = fs.readdirSync(dir)
|
|
|
|
|
const backupFiles = files.filter((f) => f.startsWith(`${basename}.bak.`))
|
|
|
|
|
expect(backupFiles.length).toBe(0)
|
|
|
|
|
})
|
2026-02-07 18:25:23 +09:00
|
|
|
})
|
|
|
|
|
|
|
|
|
|
describe("migrateModelVersions with applied migrations", () => {
|
|
|
|
|
test("skips already-applied migrations", () => {
|
|
|
|
|
// given: Config with old model and migration already applied
|
|
|
|
|
const configs = {
|
2026-03-07 02:33:32 +09:00
|
|
|
sisyphus: { model: "openai/gpt-5.4-codex" },
|
2026-02-07 18:25:23 +09:00
|
|
|
}
|
2026-03-07 02:33:32 +09:00
|
|
|
const appliedMigrations = new Set(["model-version:openai/gpt-5.4-codex->openai/gpt-5.3-codex"])
|
2026-02-07 18:25:23 +09:00
|
|
|
|
|
|
|
|
// when: Migrate model versions
|
|
|
|
|
const { migrated, changed, newMigrations } = migrateModelVersions(configs, appliedMigrations)
|
|
|
|
|
|
|
|
|
|
// then: Migration should be skipped (user reverted)
|
|
|
|
|
expect(changed).toBe(false)
|
|
|
|
|
expect(newMigrations).toEqual([])
|
2026-03-07 02:33:32 +09:00
|
|
|
expect((migrated.sisyphus as Record<string, unknown>).model).toBe("openai/gpt-5.4-codex")
|
2026-02-07 18:25:23 +09:00
|
|
|
})
|
|
|
|
|
|
|
|
|
|
test("applies new migrations not in history", () => {
|
|
|
|
|
// given: Config with old model, no migration history
|
|
|
|
|
const configs = {
|
2026-03-07 02:33:32 +09:00
|
|
|
sisyphus: { model: "openai/gpt-5.4-codex" },
|
2026-02-07 18:25:23 +09:00
|
|
|
}
|
|
|
|
|
const appliedMigrations = new Set<string>()
|
|
|
|
|
|
|
|
|
|
// when: Migrate model versions
|
|
|
|
|
const { migrated, changed, newMigrations } = migrateModelVersions(configs, appliedMigrations)
|
|
|
|
|
|
2026-03-07 02:33:32 +09:00
|
|
|
// then: gpt-5.4-codex should not be migrated
|
2026-02-21 02:22:45 +09:00
|
|
|
expect(changed).toBe(false)
|
|
|
|
|
expect(newMigrations).toEqual([])
|
2026-03-07 02:33:32 +09:00
|
|
|
expect((migrated.sisyphus as Record<string, unknown>).model).toBe("openai/gpt-5.4-codex")
|
2026-02-07 18:25:23 +09:00
|
|
|
})
|
|
|
|
|
|
|
|
|
|
test("handles mixed: skip applied, apply new", () => {
|
|
|
|
|
// given: Config with 2 old models, 1 already migrated
|
|
|
|
|
const configs = {
|
2026-03-07 02:33:32 +09:00
|
|
|
sisyphus: { model: "openai/gpt-5.4-codex" },
|
2026-02-07 18:25:23 +09:00
|
|
|
oracle: { model: "anthropic/claude-opus-4-5" },
|
|
|
|
|
}
|
2026-03-07 02:33:32 +09:00
|
|
|
const appliedMigrations = new Set(["model-version:openai/gpt-5.4-codex->openai/gpt-5.3-codex"])
|
2026-02-07 18:25:23 +09:00
|
|
|
|
|
|
|
|
// when: Migrate model versions
|
|
|
|
|
const { migrated, changed, newMigrations } = migrateModelVersions(configs, appliedMigrations)
|
|
|
|
|
|
|
|
|
|
// then: Skip sisyphus (already applied), apply oracle
|
|
|
|
|
expect(changed).toBe(true)
|
|
|
|
|
expect(newMigrations).toEqual(["model-version:anthropic/claude-opus-4-5->anthropic/claude-opus-4-6"])
|
2026-03-07 02:33:32 +09:00
|
|
|
expect((migrated.sisyphus as Record<string, unknown>).model).toBe("openai/gpt-5.4-codex")
|
2026-02-07 18:25:23 +09:00
|
|
|
expect((migrated.oracle as Record<string, unknown>).model).toBe("anthropic/claude-opus-4-6")
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
test("backward compatible: no appliedMigrations param", () => {
|
|
|
|
|
// given: Config with old model, no appliedMigrations param (legacy call)
|
|
|
|
|
const configs = {
|
2026-03-07 02:33:32 +09:00
|
|
|
sisyphus: { model: "openai/gpt-5.4-codex" },
|
2026-02-07 18:25:23 +09:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// when: Migrate model versions (without appliedMigrations)
|
|
|
|
|
const { migrated, changed, newMigrations } = migrateModelVersions(configs)
|
|
|
|
|
|
2026-03-07 02:33:32 +09:00
|
|
|
// then: gpt-5.4-codex remains unchanged
|
2026-02-21 02:22:45 +09:00
|
|
|
expect(changed).toBe(false)
|
|
|
|
|
expect(newMigrations).toEqual([])
|
2026-03-07 02:33:32 +09:00
|
|
|
expect((migrated.sisyphus as Record<string, unknown>).model).toBe("openai/gpt-5.4-codex")
|
2026-02-07 18:25:23 +09:00
|
|
|
})
|
|
|
|
|
|
|
|
|
|
test("returns empty newMigrations when no migrations applied", () => {
|
|
|
|
|
// given: Config with no old models
|
|
|
|
|
const configs = {
|
2026-03-07 02:33:32 +09:00
|
|
|
sisyphus: { model: "openai/gpt-5.4-codex" },
|
2026-02-07 18:25:23 +09:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// when: Migrate model versions
|
|
|
|
|
const { migrated, changed, newMigrations } = migrateModelVersions(configs, new Set())
|
|
|
|
|
|
|
|
|
|
// then: No migrations
|
|
|
|
|
expect(changed).toBe(false)
|
|
|
|
|
expect(newMigrations).toEqual([])
|
|
|
|
|
})
|
|
|
|
|
})
|
|
|
|
|
|
2026-04-09 10:54:08 +09:00
|
|
|
describe("migrateConfigFile with migration tracking via sidecar (#3263)", () => {
|
2026-02-07 18:25:23 +09:00
|
|
|
const cleanupPaths: string[] = []
|
|
|
|
|
|
|
|
|
|
afterEach(() => {
|
|
|
|
|
for (const p of cleanupPaths) {
|
|
|
|
|
try {
|
|
|
|
|
fs.unlinkSync(p)
|
|
|
|
|
} catch {
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
cleanupPaths.length = 0
|
|
|
|
|
})
|
|
|
|
|
|
2026-04-09 10:54:08 +09:00
|
|
|
function tempConfigPath(label: string): string {
|
|
|
|
|
const workdir = fs.mkdtempSync(`/tmp/omo-migration-${label}-`)
|
|
|
|
|
cleanupPaths.push(workdir)
|
|
|
|
|
return path.join(workdir, "oh-my-openagent.json")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function sidecarPath(configPath: string): string {
|
|
|
|
|
return `${configPath}.migrations.json`
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
test("does not emit migration history when no migration applies", () => {
|
|
|
|
|
// given: Config with a model that does not appear in MODEL_VERSION_MAP
|
|
|
|
|
const testConfigPath = tempConfigPath("no-op")
|
2026-02-07 18:25:23 +09:00
|
|
|
const rawConfig: Record<string, unknown> = {
|
|
|
|
|
agents: {
|
2026-03-07 02:33:32 +09:00
|
|
|
sisyphus: { model: "openai/gpt-5.4-codex" },
|
2026-02-07 18:25:23 +09:00
|
|
|
},
|
|
|
|
|
}
|
|
|
|
|
fs.writeFileSync(testConfigPath, JSON.stringify(rawConfig, null, 2))
|
|
|
|
|
|
|
|
|
|
const needsWrite = migrateConfigFile(testConfigPath, rawConfig)
|
|
|
|
|
|
2026-02-21 02:22:45 +09:00
|
|
|
expect(needsWrite).toBe(false)
|
|
|
|
|
expect(rawConfig._migrations).toBeUndefined()
|
2026-03-07 02:33:32 +09:00
|
|
|
expect((rawConfig.agents as Record<string, Record<string, unknown>>).sisyphus.model).toBe("openai/gpt-5.4-codex")
|
2026-04-09 10:54:08 +09:00
|
|
|
expect(fs.existsSync(sidecarPath(testConfigPath))).toBe(false)
|
2026-02-07 18:25:23 +09:00
|
|
|
})
|
|
|
|
|
|
2026-04-09 10:54:08 +09:00
|
|
|
test("writes applied migrations to sidecar instead of leaving them on the config", () => {
|
|
|
|
|
// given: Config that needs a real model migration and has no prior history
|
|
|
|
|
const testConfigPath = tempConfigPath("sidecar-write")
|
2026-02-07 18:25:23 +09:00
|
|
|
const rawConfig: Record<string, unknown> = {
|
|
|
|
|
agents: {
|
2026-04-09 10:54:08 +09:00
|
|
|
oracle: { model: "anthropic/claude-opus-4-5" },
|
|
|
|
|
},
|
|
|
|
|
}
|
|
|
|
|
fs.writeFileSync(testConfigPath, JSON.stringify(rawConfig, null, 2))
|
|
|
|
|
|
|
|
|
|
const needsWrite = migrateConfigFile(testConfigPath, rawConfig)
|
|
|
|
|
|
|
|
|
|
expect(needsWrite).toBe(true)
|
|
|
|
|
expect((rawConfig.agents as Record<string, Record<string, unknown>>).oracle.model).toBe("anthropic/claude-opus-4-6")
|
|
|
|
|
expect(rawConfig._migrations).toBeUndefined()
|
|
|
|
|
|
|
|
|
|
const sidecar = JSON.parse(fs.readFileSync(sidecarPath(testConfigPath), "utf-8"))
|
|
|
|
|
expect(sidecar.appliedMigrations).toEqual([
|
|
|
|
|
"model-version:anthropic/claude-opus-4-5->anthropic/claude-opus-4-6",
|
|
|
|
|
])
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
test("skips re-applying a migration that is recorded in the sidecar even if the user edited _migrations away", () => {
|
|
|
|
|
// This is the core #3263 regression: a user auto-migrated from
|
|
|
|
|
// gpt-5.3-codex to gpt-5.4, reverted to gpt-5.3-codex by hand, and
|
|
|
|
|
// deleted _migrations in the process. Without the sidecar their
|
|
|
|
|
// revert was clobbered on every startup.
|
|
|
|
|
const testConfigPath = tempConfigPath("sidecar-revert")
|
|
|
|
|
fs.writeFileSync(
|
|
|
|
|
sidecarPath(testConfigPath),
|
|
|
|
|
JSON.stringify({
|
|
|
|
|
appliedMigrations: ["model-version:openai/gpt-5.3-codex->openai/gpt-5.4"],
|
|
|
|
|
}),
|
|
|
|
|
)
|
|
|
|
|
const rawConfig: Record<string, unknown> = {
|
|
|
|
|
agents: {
|
|
|
|
|
oracle: { model: "openai/gpt-5.3-codex" },
|
2026-02-07 18:25:23 +09:00
|
|
|
},
|
|
|
|
|
}
|
|
|
|
|
fs.writeFileSync(testConfigPath, JSON.stringify(rawConfig, null, 2))
|
|
|
|
|
|
|
|
|
|
const needsWrite = migrateConfigFile(testConfigPath, rawConfig)
|
|
|
|
|
|
|
|
|
|
expect(needsWrite).toBe(false)
|
2026-04-09 10:54:08 +09:00
|
|
|
expect((rawConfig.agents as Record<string, Record<string, unknown>>).oracle.model).toBe("openai/gpt-5.3-codex")
|
|
|
|
|
expect(rawConfig._migrations).toBeUndefined()
|
2026-02-07 18:25:23 +09:00
|
|
|
})
|
|
|
|
|
|
2026-04-09 10:54:08 +09:00
|
|
|
test("mirrors legacy in-config _migrations into the sidecar and then strips the field", () => {
|
|
|
|
|
// BC path: configs written by older OMO versions still carry the
|
|
|
|
|
// legacy _migrations field in the JSON body. On the next startup we
|
|
|
|
|
// must copy that history into the new sidecar and remove the field
|
|
|
|
|
// from the config so the migration tracking lives in exactly one
|
|
|
|
|
// place from then on.
|
|
|
|
|
const testConfigPath = tempConfigPath("bc-mirror")
|
|
|
|
|
const rawConfig: Record<string, unknown> = {
|
|
|
|
|
agents: {
|
|
|
|
|
oracle: { model: "openai/gpt-5.3-codex" },
|
|
|
|
|
},
|
|
|
|
|
_migrations: ["model-version:openai/gpt-5.3-codex->openai/gpt-5.4"],
|
|
|
|
|
}
|
|
|
|
|
fs.writeFileSync(testConfigPath, JSON.stringify(rawConfig, null, 2))
|
|
|
|
|
|
|
|
|
|
const needsWrite = migrateConfigFile(testConfigPath, rawConfig)
|
|
|
|
|
|
|
|
|
|
// needsWrite is true because we rewrote the config to drop _migrations
|
|
|
|
|
expect(needsWrite).toBe(true)
|
|
|
|
|
expect(rawConfig._migrations).toBeUndefined()
|
|
|
|
|
expect((rawConfig.agents as Record<string, Record<string, unknown>>).oracle.model).toBe("openai/gpt-5.3-codex")
|
|
|
|
|
|
|
|
|
|
const sidecar = JSON.parse(fs.readFileSync(sidecarPath(testConfigPath), "utf-8"))
|
|
|
|
|
expect(sidecar.appliedMigrations).toEqual([
|
|
|
|
|
"model-version:openai/gpt-5.3-codex->openai/gpt-5.4",
|
|
|
|
|
])
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
test("unions sidecar and legacy _migrations entries, deduplicating", () => {
|
|
|
|
|
// Defensive case: a config written by two different OMO versions
|
|
|
|
|
// could end up with an entry in _migrations that is also in the
|
|
|
|
|
// sidecar. The merged set should be deduplicated and the config
|
|
|
|
|
// should not be re-migrated.
|
|
|
|
|
const testConfigPath = tempConfigPath("sidecar-union")
|
|
|
|
|
fs.writeFileSync(
|
|
|
|
|
sidecarPath(testConfigPath),
|
|
|
|
|
JSON.stringify({
|
|
|
|
|
appliedMigrations: [
|
|
|
|
|
"model-version:openai/gpt-5.3-codex->openai/gpt-5.4",
|
|
|
|
|
"model-version:anthropic/claude-opus-4-5->anthropic/claude-opus-4-6",
|
|
|
|
|
],
|
|
|
|
|
}),
|
|
|
|
|
)
|
2026-02-07 18:25:23 +09:00
|
|
|
const rawConfig: Record<string, unknown> = {
|
|
|
|
|
agents: {
|
|
|
|
|
oracle: { model: "anthropic/claude-opus-4-5" },
|
|
|
|
|
},
|
2026-04-09 10:54:08 +09:00
|
|
|
_migrations: ["model-version:anthropic/claude-opus-4-5->anthropic/claude-opus-4-6"],
|
2026-02-07 18:25:23 +09:00
|
|
|
}
|
|
|
|
|
fs.writeFileSync(testConfigPath, JSON.stringify(rawConfig, null, 2))
|
|
|
|
|
|
|
|
|
|
const needsWrite = migrateConfigFile(testConfigPath, rawConfig)
|
|
|
|
|
|
2026-04-09 10:54:08 +09:00
|
|
|
// needsWrite because the legacy _migrations field was stripped
|
2026-02-07 18:25:23 +09:00
|
|
|
expect(needsWrite).toBe(true)
|
2026-04-09 10:54:08 +09:00
|
|
|
expect(rawConfig._migrations).toBeUndefined()
|
|
|
|
|
// The reverted opus-4-5 value must be preserved
|
|
|
|
|
expect((rawConfig.agents as Record<string, Record<string, unknown>>).oracle.model).toBe("anthropic/claude-opus-4-5")
|
|
|
|
|
|
|
|
|
|
const sidecar = JSON.parse(fs.readFileSync(sidecarPath(testConfigPath), "utf-8"))
|
|
|
|
|
expect(sidecar.appliedMigrations).toEqual([
|
2026-02-07 18:25:23 +09:00
|
|
|
"model-version:anthropic/claude-opus-4-5->anthropic/claude-opus-4-6",
|
2026-04-09 10:54:08 +09:00
|
|
|
"model-version:openai/gpt-5.3-codex->openai/gpt-5.4",
|
2026-02-07 18:25:23 +09:00
|
|
|
])
|
|
|
|
|
})
|
2026-01-09 02:24:43 +09:00
|
|
|
|
2026-04-09 10:54:08 +09:00
|
|
|
test("appends new migrations to the sidecar when partial history exists", () => {
|
|
|
|
|
// Scenario: sidecar already has one migration, a second model still
|
|
|
|
|
// needs to be migrated. The new migration should be recorded and the
|
|
|
|
|
// already-applied one preserved.
|
|
|
|
|
const testConfigPath = tempConfigPath("sidecar-append")
|
|
|
|
|
fs.writeFileSync(
|
|
|
|
|
sidecarPath(testConfigPath),
|
|
|
|
|
JSON.stringify({
|
|
|
|
|
appliedMigrations: ["model-version:openai/gpt-5.3-codex->openai/gpt-5.4"],
|
|
|
|
|
}),
|
|
|
|
|
)
|
|
|
|
|
const rawConfig: Record<string, unknown> = {
|
|
|
|
|
agents: {
|
|
|
|
|
codex: { model: "openai/gpt-5.3-codex" },
|
|
|
|
|
claude: { model: "anthropic/claude-opus-4-5" },
|
|
|
|
|
},
|
|
|
|
|
}
|
|
|
|
|
fs.writeFileSync(testConfigPath, JSON.stringify(rawConfig, null, 2))
|
|
|
|
|
|
|
|
|
|
const needsWrite = migrateConfigFile(testConfigPath, rawConfig)
|
|
|
|
|
|
|
|
|
|
expect(needsWrite).toBe(true)
|
|
|
|
|
// codex was reverted, must stay
|
|
|
|
|
expect((rawConfig.agents as Record<string, Record<string, unknown>>).codex.model).toBe("openai/gpt-5.3-codex")
|
|
|
|
|
// claude migrates
|
|
|
|
|
expect((rawConfig.agents as Record<string, Record<string, unknown>>).claude.model).toBe("anthropic/claude-opus-4-6")
|
|
|
|
|
expect(rawConfig._migrations).toBeUndefined()
|
2026-01-09 02:24:43 +09:00
|
|
|
|
2026-04-09 10:54:08 +09:00
|
|
|
const sidecar = JSON.parse(fs.readFileSync(sidecarPath(testConfigPath), "utf-8"))
|
|
|
|
|
expect(new Set(sidecar.appliedMigrations)).toEqual(new Set([
|
|
|
|
|
"model-version:openai/gpt-5.3-codex->openai/gpt-5.4",
|
|
|
|
|
"model-version:anthropic/claude-opus-4-5->anthropic/claude-opus-4-6",
|
|
|
|
|
]))
|
|
|
|
|
})
|
2026-04-10 18:47:18 +09:00
|
|
|
|
|
|
|
|
test("preserves _migrations in config when sidecar write fails", () => {
|
|
|
|
|
// given: Config with _migrations field and a read-only directory that will cause sidecar write to fail
|
|
|
|
|
const testConfigPath = tempConfigPath("sidecar-fail")
|
|
|
|
|
const rawConfig: Record<string, unknown> = {
|
|
|
|
|
agents: {
|
|
|
|
|
oracle: { model: "anthropic/claude-opus-4-5" },
|
|
|
|
|
},
|
|
|
|
|
_migrations: ["model-version:openai/gpt-5.3-codex->openai/gpt-5.4"],
|
|
|
|
|
}
|
|
|
|
|
fs.writeFileSync(testConfigPath, JSON.stringify(rawConfig, null, 2))
|
|
|
|
|
|
|
|
|
|
// Make the directory read-only to cause sidecar write to fail
|
|
|
|
|
const workdir = path.dirname(testConfigPath)
|
|
|
|
|
fs.chmodSync(workdir, 0o555)
|
|
|
|
|
|
|
|
|
|
// when: Migrate config file (sidecar write will fail)
|
|
|
|
|
const needsWrite = migrateConfigFile(testConfigPath, rawConfig)
|
|
|
|
|
|
2026-04-10 19:01:59 +09:00
|
|
|
// then: _migrations should contain full set (existing + new) as fallback
|
2026-04-10 18:47:18 +09:00
|
|
|
expect(needsWrite).toBe(true)
|
2026-04-10 19:01:59 +09:00
|
|
|
const migrations = rawConfig._migrations as string[]
|
|
|
|
|
expect(Array.isArray(migrations)).toBe(true)
|
|
|
|
|
expect(migrations).toContain("model-version:openai/gpt-5.3-codex->openai/gpt-5.4")
|
|
|
|
|
expect(migrations.length).toBeGreaterThanOrEqual(1)
|
2026-04-10 18:47:18 +09:00
|
|
|
expect((rawConfig.agents as Record<string, Record<string, unknown>>).oracle.model).toBe("anthropic/claude-opus-4-6")
|
|
|
|
|
|
|
|
|
|
// Sidecar should not exist because write failed
|
|
|
|
|
expect(fs.existsSync(sidecarPath(testConfigPath))).toBe(false)
|
|
|
|
|
|
|
|
|
|
// cleanup: restore permissions for cleanup
|
|
|
|
|
fs.chmodSync(workdir, 0o755)
|
|
|
|
|
})
|
2026-04-10 19:01:59 +09:00
|
|
|
|
|
|
|
|
test("writes _migrations into config as fallback when sidecar write fails and no prior _migrations existed", () => {
|
|
|
|
|
// given: config WITHOUT _migrations field and a read-only dir
|
|
|
|
|
const testConfigPath = tempConfigPath("sidecar-fail-no-prior")
|
|
|
|
|
const rawConfig: Record<string, unknown> = {
|
|
|
|
|
agents: {
|
|
|
|
|
oracle: { model: "anthropic/claude-opus-4-5" },
|
|
|
|
|
},
|
|
|
|
|
}
|
|
|
|
|
fs.writeFileSync(testConfigPath, JSON.stringify(rawConfig, null, 2))
|
|
|
|
|
const workdir = path.dirname(testConfigPath)
|
|
|
|
|
fs.chmodSync(workdir, 0o555)
|
|
|
|
|
|
|
|
|
|
// when: migrate runs (sidecar write will fail)
|
|
|
|
|
const needsWrite = migrateConfigFile(testConfigPath, rawConfig)
|
|
|
|
|
|
|
|
|
|
// then: _migrations should be injected into config as fallback
|
|
|
|
|
expect(needsWrite).toBe(true)
|
|
|
|
|
expect(rawConfig._migrations).toBeDefined()
|
|
|
|
|
expect(Array.isArray(rawConfig._migrations)).toBe(true)
|
|
|
|
|
expect((rawConfig._migrations as string[]).length).toBeGreaterThan(0)
|
|
|
|
|
expect(fs.existsSync(sidecarPath(testConfigPath))).toBe(false)
|
|
|
|
|
|
|
|
|
|
// cleanup
|
|
|
|
|
fs.chmodSync(workdir, 0o755)
|
|
|
|
|
})
|
2026-01-09 02:24:43 +09:00
|
|
|
})
|