2026-05-08 16:08:18 +09:00
import { afterEach , describe , expect , it , mock } from "bun:test" ;
2026-05-04 19:36:23 +09:00
import { chmodSync , existsSync , mkdtempSync , mkdirSync , readFileSync , realpathSync , rmSync , writeFileSync } from "node:fs"
2026-04-03 17:45:02 +09:00
import { tmpdir } from "node:os"
import { join } from "node:path"
2026-05-08 16:08:18 +09:00
import { loadConfigFromPath , mergeConfigs , parseConfigPartially } from "./plugin-config" ;
2026-04-28 10:44:53 +09:00
import { OhMyOpenCodeConfigSchema , type OhMyOpenCodeConfig , type TeamModeConfig } from "./config" ;
2026-05-08 16:08:18 +09:00
import { clearConfigLoadErrors , getConfigLoadErrors } from "./shared/config-errors" ;
2026-01-13 22:48:13 +09:00
2026-04-03 17:45:02 +09:00
const tempDirs : string [ ] = [ ]
2026-04-28 10:44:53 +09:00
type ConfigInput = Omit < Partial < OhMyOpenCodeConfig > , "team_mode" > & {
team_mode? : Partial < TeamModeConfig >
}
2026-04-03 17:45:02 +09:00
2026-04-28 10:44:53 +09:00
function createConfig ( config : ConfigInput ) : OhMyOpenCodeConfig {
2026-04-04 14:33:52 +09:00
return OhMyOpenCodeConfigSchema . parse ( config )
}
2026-04-10 15:53:25 +09:00
async function importFreshPluginConfigModule ( ) : Promise < typeof import ( "./plugin-config" ) > {
return import ( ` ./plugin-config?test= ${ Date . now ( ) } - ${ Math . random ( ) } ` )
}
2026-04-03 17:45:02 +09:00
afterEach ( ( ) = > {
mock . restore ( )
2026-05-08 16:08:18 +09:00
clearConfigLoadErrors ( )
2026-04-28 10:44:53 +09:00
delete process . env . OPENCODE_CONFIG_DIR
2026-04-03 17:45:02 +09:00
for ( const dir of tempDirs . splice ( 0 ) ) {
rmSync ( dir , { recursive : true , force : true } )
}
} )
2026-04-28 10:44:53 +09:00
function createLoadPluginConfigTestContext ( prefix : string ) : {
rootDir : string
userConfigDir : string
projectDir : string
projectConfigDir : string
} {
const rootDir = mkdtempSync ( join ( tmpdir ( ) , prefix ) )
const userConfigDir = join ( rootDir , "user-config" )
const projectDir = join ( rootDir , "project" )
const projectConfigDir = join ( projectDir , ".opencode" )
tempDirs . push ( rootDir )
mkdirSync ( userConfigDir , { recursive : true } )
mkdirSync ( projectConfigDir , { recursive : true } )
return { rootDir , userConfigDir , projectDir , projectConfigDir }
}
function writeJsonFile ( filePath : string , value : Record < string , unknown > ) : void {
writeFileSync ( filePath , JSON . stringify ( value ) )
}
2026-01-13 22:48:13 +09:00
describe ( "mergeConfigs" , ( ) = > {
describe ( "categories merging" , ( ) = > {
2026-02-01 16:47:50 +09:00
// given base config has categories, override has different categories
// when merging configs
// then should deep merge categories, not override completely
2026-01-13 22:48:13 +09:00
it ( "should deep merge categories from base and override" , ( ) = > {
2026-04-04 14:33:52 +09:00
const base = createConfig ( {
2026-01-13 22:48:13 +09:00
categories : {
general : {
2026-03-07 02:33:32 +09:00
model : "openai/gpt-5.4" ,
2026-01-13 22:48:13 +09:00
temperature : 0.5 ,
} ,
quick : {
model : "anthropic/claude-haiku-4-5" ,
} ,
} ,
2026-04-04 14:33:52 +09:00
} ) ;
2026-01-13 22:48:13 +09:00
2026-04-04 14:33:52 +09:00
const override = createConfig ( {
2026-01-13 22:48:13 +09:00
categories : {
general : {
temperature : 0.3 ,
} ,
visual : {
2026-02-26 21:01:05 +09:00
model : "google/gemini-3.1-pro" ,
2026-01-13 22:48:13 +09:00
} ,
} ,
2026-04-04 14:33:52 +09:00
} ) ;
2026-01-13 22:48:13 +09:00
const result = mergeConfigs ( base , override ) ;
2026-02-01 16:47:50 +09:00
// then general.model should be preserved from base
2026-03-07 02:33:32 +09:00
expect ( result . categories ? . general ? . model ) . toBe ( "openai/gpt-5.4" ) ;
2026-02-01 16:47:50 +09:00
// then general.temperature should be overridden
2026-01-13 22:48:13 +09:00
expect ( result . categories ? . general ? . temperature ) . toBe ( 0.3 ) ;
2026-02-01 16:47:50 +09:00
// then quick should be preserved from base
2026-01-13 22:48:13 +09:00
expect ( result . categories ? . quick ? . model ) . toBe ( "anthropic/claude-haiku-4-5" ) ;
2026-02-01 16:47:50 +09:00
// then visual should be added from override
2026-02-26 21:01:05 +09:00
expect ( result . categories ? . visual ? . model ) . toBe ( "google/gemini-3.1-pro" ) ;
2026-01-13 22:48:13 +09:00
} ) ;
it ( "should preserve base categories when override has no categories" , ( ) = > {
2026-04-04 14:33:52 +09:00
const base = createConfig ( {
2026-01-13 22:48:13 +09:00
categories : {
general : {
2026-03-07 02:33:32 +09:00
model : "openai/gpt-5.4" ,
2026-01-13 22:48:13 +09:00
} ,
} ,
2026-04-04 14:33:52 +09:00
} ) ;
2026-01-13 22:48:13 +09:00
2026-04-04 14:33:52 +09:00
const override = createConfig ( { } ) ;
2026-01-13 22:48:13 +09:00
const result = mergeConfigs ( base , override ) ;
2026-03-07 02:33:32 +09:00
expect ( result . categories ? . general ? . model ) . toBe ( "openai/gpt-5.4" ) ;
2026-01-13 22:48:13 +09:00
} ) ;
it ( "should use override categories when base has no categories" , ( ) = > {
2026-04-04 14:33:52 +09:00
const base = createConfig ( { } ) ;
2026-01-13 22:48:13 +09:00
2026-04-04 14:33:52 +09:00
const override = createConfig ( {
2026-01-13 22:48:13 +09:00
categories : {
general : {
2026-03-07 02:33:32 +09:00
model : "openai/gpt-5.4" ,
2026-01-13 22:48:13 +09:00
} ,
} ,
2026-04-04 14:33:52 +09:00
} ) ;
2026-01-13 22:48:13 +09:00
const result = mergeConfigs ( base , override ) ;
2026-03-07 02:33:32 +09:00
expect ( result . categories ? . general ? . model ) . toBe ( "openai/gpt-5.4" ) ;
2026-01-13 22:48:13 +09:00
} ) ;
} ) ;
describe ( "existing behavior preservation" , ( ) = > {
it ( "should deep merge agents" , ( ) = > {
2026-04-04 14:33:52 +09:00
const base = createConfig ( {
2026-01-13 22:48:13 +09:00
agents : {
2026-04-24 16:17:44 +09:00
oracle : { model : "openai/gpt-5.5" } ,
2026-01-13 22:48:13 +09:00
} ,
2026-04-04 14:33:52 +09:00
} ) ;
2026-01-13 22:48:13 +09:00
2026-04-04 14:33:52 +09:00
const override = createConfig ( {
2026-01-13 22:48:13 +09:00
agents : {
oracle : { temperature : 0.5 } ,
explore : { model : "anthropic/claude-haiku-4-5" } ,
} ,
2026-04-04 14:33:52 +09:00
} ) ;
2026-01-13 22:48:13 +09:00
const result = mergeConfigs ( base , override ) ;
2026-04-24 16:17:44 +09:00
expect ( result . agents ? . oracle ) . toMatchObject ( { model : "openai/gpt-5.5" } ) ;
2026-01-13 22:48:13 +09:00
expect ( result . agents ? . oracle ? . temperature ) . toBe ( 0.5 ) ;
2026-03-14 13:46:56 +09:00
expect ( result . agents ? . explore ) . toMatchObject ( { model : "anthropic/claude-haiku-4-5" } ) ;
2026-01-13 22:48:13 +09:00
} ) ;
2026-04-28 10:44:53 +09:00
it ( "should deep merge team_mode" , ( ) = > {
const base = createConfig ( {
team_mode : {
enabled : false ,
tmux_visualization : false ,
max_parallel_members : 2 ,
} ,
} ) ;
const override = {
team_mode : {
enabled : true ,
} ,
} as OhMyOpenCodeConfig ;
const result = mergeConfigs ( base , override ) ;
expect ( result . team_mode ) . toMatchObject ( {
enabled : true ,
max_parallel_members : 2 ,
} ) ;
} ) ;
2026-01-13 22:48:13 +09:00
it ( "should merge disabled arrays without duplicates" , ( ) = > {
2026-04-04 14:33:52 +09:00
const base = createConfig ( {
2026-01-13 22:48:13 +09:00
disabled_hooks : [ "comment-checker" , "think-mode" ] ,
2026-04-04 14:33:52 +09:00
} ) ;
2026-01-13 22:48:13 +09:00
2026-04-04 14:33:52 +09:00
const override = createConfig ( {
2026-01-13 22:48:13 +09:00
disabled_hooks : [ "think-mode" , "session-recovery" ] ,
2026-04-04 14:33:52 +09:00
} ) ;
2026-01-13 22:48:13 +09:00
const result = mergeConfigs ( base , override ) ;
expect ( result . disabled_hooks ) . toContain ( "comment-checker" ) ;
expect ( result . disabled_hooks ) . toContain ( "think-mode" ) ;
expect ( result . disabled_hooks ) . toContain ( "session-recovery" ) ;
expect ( result . disabled_hooks ? . length ) . toBe ( 3 ) ;
} ) ;
2026-03-13 21:35:46 +09:00
it ( "should union disabled_tools from base and override without duplicates" , ( ) = > {
2026-04-04 14:33:52 +09:00
const base = createConfig ( {
2026-03-13 21:35:46 +09:00
disabled_tools : [ "todowrite" , "interactive_bash" ] ,
2026-04-04 14:33:52 +09:00
} ) ;
2026-03-13 21:35:46 +09:00
2026-04-04 14:33:52 +09:00
const override = createConfig ( {
2026-03-13 21:35:46 +09:00
disabled_tools : [ "interactive_bash" , "look_at" ] ,
2026-04-04 14:33:52 +09:00
} ) ;
2026-03-13 21:35:46 +09:00
const result = mergeConfigs ( base , override ) ;
expect ( result . disabled_tools ) . toContain ( "todowrite" ) ;
expect ( result . disabled_tools ) . toContain ( "interactive_bash" ) ;
expect ( result . disabled_tools ) . toContain ( "look_at" ) ;
expect ( result . disabled_tools ? . length ) . toBe ( 3 ) ;
} ) ;
2026-01-13 22:48:13 +09:00
} ) ;
} ) ;
2026-02-12 01:33:12 +05:30
2026-04-28 10:44:53 +09:00
2026-02-12 01:33:12 +05:30
describe ( "parseConfigPartially" , ( ) = > {
2026-03-14 13:46:56 +09:00
describe ( "disabled_hooks compatibility" , ( ) = > {
//#given a config with a future hook name unknown to this version
//#when validating against the full config schema
//#then should accept the hook name so runtime and schema stay aligned
it ( "should accept unknown disabled_hooks values for forward compatibility" , ( ) = > {
const result = OhMyOpenCodeConfigSchema . safeParse ( {
disabled_hooks : [ "future-hook-name" ] ,
} ) ;
expect ( result . success ) . toBe ( true ) ;
if ( result . success ) {
expect ( result . data . disabled_hooks ) . toEqual ( [ "future-hook-name" ] ) ;
}
} ) ;
} ) ;
2026-02-12 01:33:12 +05:30
describe ( "fully valid config" , ( ) = > {
//#given a config where all sections are valid
//#when parsing the config
//#then should return the full parsed config unchanged
it ( "should return the full config when everything is valid" , ( ) = > {
const rawConfig = {
agents : {
2026-04-24 16:17:44 +09:00
oracle : { model : "openai/gpt-5.5" } ,
2026-03-07 02:33:32 +09:00
momus : { model : "openai/gpt-5.4" } ,
2026-02-12 01:33:12 +05:30
} ,
disabled_hooks : [ "comment-checker" ] ,
} ;
const result = parseConfigPartially ( rawConfig ) ;
expect ( result ) . not . toBeNull ( ) ;
2026-04-24 16:17:44 +09:00
expect ( result ! . agents ? . oracle ) . toMatchObject ( { model : "openai/gpt-5.5" } ) ;
2026-03-14 13:46:56 +09:00
expect ( result ! . agents ? . momus ) . toMatchObject ( { model : "openai/gpt-5.4" } ) ;
2026-02-12 01:33:12 +05:30
expect ( result ! . disabled_hooks ) . toEqual ( [ "comment-checker" ] ) ;
} ) ;
} ) ;
describe ( "partially invalid config" , ( ) = > {
//#given a config where one section is invalid but others are valid
//#when parsing the config
//#then should return valid sections and skip invalid ones
it ( "should preserve valid agent overrides when another section is invalid" , ( ) = > {
const rawConfig = {
agents : {
2026-04-24 16:17:44 +09:00
oracle : { model : "openai/gpt-5.5" } ,
2026-03-07 02:33:32 +09:00
momus : { model : "openai/gpt-5.4" } ,
2026-02-12 01:33:12 +05:30
prometheus : {
permission : {
2026-05-16 17:41:35 +09:00
edit : { "*" : "ask" , ".omo/**" : "allow" } ,
2026-02-12 01:33:12 +05:30
} ,
} ,
} ,
disabled_hooks : [ "comment-checker" ] ,
} ;
const result = parseConfigPartially ( rawConfig ) ;
expect ( result ) . not . toBeNull ( ) ;
expect ( result ! . disabled_hooks ) . toEqual ( [ "comment-checker" ] ) ;
2026-03-02 23:55:48 +09:00
expect ( result ! . agents ) . toBeUndefined ( ) ;
2026-02-12 01:33:12 +05:30
} ) ;
2026-05-08 16:08:18 +09:00
it ( "should preserve valid agent_order when another section is invalid" , ( ) = > {
const rawConfig = {
agent_order : [ "hephaestus" , "sisyphus" , "prometheus" , "atlas" ] ,
disabled_skills : [ 42 ] ,
} ;
const result = parseConfigPartially ( rawConfig ) ;
expect ( result ? . agent_order ) . toEqual ( [
"hephaestus" ,
"sisyphus" ,
"prometheus" ,
"atlas" ,
] ) ;
expect ( result ? . disabled_skills ) . toBeUndefined ( ) ;
} ) ;
it ( "should skip abusive agent_order when another section is valid" , ( ) = > {
const rawConfig = {
agent_order : [ "x" . repeat ( 129 ) ] ,
disabled_hooks : [ "comment-checker" ] ,
} ;
const result = parseConfigPartially ( rawConfig ) ;
expect ( result ? . agent_order ) . toBeUndefined ( ) ;
expect ( result ? . disabled_hooks ) . toEqual ( [ "comment-checker" ] ) ;
} ) ;
2026-02-12 01:33:12 +05:30
it ( "should preserve valid agents when a non-agent section is invalid" , ( ) = > {
const rawConfig = {
agents : {
2026-04-24 16:17:44 +09:00
oracle : { model : "openai/gpt-5.5" } ,
2026-02-12 01:33:12 +05:30
} ,
disabled_hooks : [ "not-a-real-hook" ] ,
} ;
const result = parseConfigPartially ( rawConfig ) ;
expect ( result ) . not . toBeNull ( ) ;
2026-04-24 16:17:44 +09:00
expect ( result ! . agents ? . oracle ) . toMatchObject ( { model : "openai/gpt-5.5" } ) ;
2026-02-21 16:24:18 +09:00
expect ( result ! . disabled_hooks ) . toEqual ( [ "not-a-real-hook" ] ) ;
2026-02-12 01:33:12 +05:30
} ) ;
} ) ;
describe ( "completely invalid config" , ( ) = > {
//#given a config where all sections are invalid
//#when parsing the config
//#then should return an empty object (not null)
it ( "should return empty object when all sections are invalid" , ( ) = > {
const rawConfig = {
agents : { oracle : { temperature : "not-a-number" } } ,
disabled_hooks : [ "not-a-real-hook" ] ,
} ;
const result = parseConfigPartially ( rawConfig ) ;
expect ( result ) . not . toBeNull ( ) ;
expect ( result ! . agents ) . toBeUndefined ( ) ;
2026-02-21 16:24:18 +09:00
expect ( result ! . disabled_hooks ) . toEqual ( [ "not-a-real-hook" ] ) ;
2026-02-12 01:33:12 +05:30
} ) ;
} ) ;
describe ( "empty config" , ( ) = > {
//#given an empty config object
//#when parsing the config
//#then should return an empty object (fast path - full parse succeeds)
it ( "should return empty object for empty input" , ( ) = > {
const result = parseConfigPartially ( { } ) ;
expect ( result ) . not . toBeNull ( ) ;
2026-03-28 15:24:18 +09:00
expect ( result ) . toEqual ( {
git_master : {
commit_footer : true ,
include_co_authored_by : true ,
git_env_prefix : "GIT_MASTER=1" ,
} ,
} ) ;
2026-02-12 01:33:12 +05:30
} ) ;
} ) ;
describe ( "unknown keys" , ( ) = > {
//#given a config with keys not in the schema
//#when parsing the config
//#then should silently ignore unknown keys and preserve valid ones
it ( "should ignore unknown keys and return valid sections" , ( ) = > {
const rawConfig = {
agents : {
2026-04-24 16:17:44 +09:00
oracle : { model : "openai/gpt-5.5" } ,
2026-02-12 01:33:12 +05:30
} ,
some_future_key : { foo : "bar" } ,
} ;
const result = parseConfigPartially ( rawConfig ) ;
expect ( result ) . not . toBeNull ( ) ;
2026-04-24 16:17:44 +09:00
expect ( result ! . agents ? . oracle ) . toMatchObject ( { model : "openai/gpt-5.5" } ) ;
2026-02-12 01:33:12 +05:30
expect ( ( result as Record < string , unknown > ) [ "some_future_key" ] ) . toBeUndefined ( ) ;
} ) ;
} ) ;
} ) ;
2026-04-03 17:45:02 +09:00
2026-05-08 16:08:18 +09:00
describe ( "loadConfigFromPath agent_order warnings" , ( ) = > {
it ( "loads config and records warning for invalid agent_order entries" , ( ) = > {
// given
const rootDir = mkdtempSync ( join ( tmpdir ( ) , "agent-order-warning-" ) )
tempDirs . push ( rootDir )
const configPath = join ( rootDir , "oh-my-openagent.json" )
writeJsonFile ( configPath , {
agent_order : [ "hephaestus" , "not-real" , "sisyphus" , "hephaestus" ] ,
} )
// when
const result = loadConfigFromPath ( configPath , { } )
// then
expect ( result ? . agent_order ) . toEqual ( [ "hephaestus" , "not-real" , "sisyphus" , "hephaestus" ] )
expect ( getConfigLoadErrors ( ) ) . toEqual ( [
{
path : configPath ,
error : 'agent_order warning - unknown agent names ignored: "not-real"; duplicate agent names ignored: "hephaestus"' ,
} ,
] )
} )
it ( "sanitizes and caps invalid agent_order values before recording warnings" , ( ) = > {
// given
const rootDir = mkdtempSync ( join ( tmpdir ( ) , "agent-order-sanitize-" ) )
tempDirs . push ( rootDir )
const configPath = join ( rootDir , "oh-my-openagent.json" )
writeJsonFile ( configPath , {
agent_order : [
"\u001B[31mbad\u001B[0m" ,
. . . Array . from ( { length : 11 } , ( _ , index ) = > ` missing- ${ index } ` ) ,
] ,
} )
// when
loadConfigFromPath ( configPath , { } )
// then
expect ( getConfigLoadErrors ( ) [ 0 ] ? . error ) . toBe (
'agent_order warning - unknown agent names ignored: "[31mbad[0m", "missing-0", "missing-1", "missing-2", "missing-3", "missing-4", "missing-5", "missing-6", "missing-7", "missing-8", (+2 more)' ,
)
} )
} )
2026-04-03 17:45:02 +09:00
describe ( "loadPluginConfig" , ( ) = > {
2026-04-10 15:53:25 +09:00
it ( "should only honor mcp_env_allowlist from user config" , async ( ) = > {
2026-04-03 17:45:02 +09:00
// given
const rootDir = mkdtempSync ( join ( tmpdir ( ) , "omo-plugin-config-" ) )
const userConfigDir = join ( rootDir , "user-config" )
const projectDir = join ( rootDir , "project" )
const projectConfigDir = join ( projectDir , ".opencode" )
tempDirs . push ( rootDir )
mkdirSync ( userConfigDir , { recursive : true } )
mkdirSync ( projectConfigDir , { recursive : true } )
writeFileSync (
join ( userConfigDir , "oh-my-openagent.jsonc" ) ,
JSON . stringify ( { mcp_env_allowlist : [ "USER_ONLY_TOKEN" ] } )
)
writeFileSync (
join ( projectConfigDir , "oh-my-openagent.jsonc" ) ,
JSON . stringify ( { mcp_env_allowlist : [ "PROJECT_TOKEN" ] } )
)
2026-04-10 15:53:25 +09:00
process . env . OPENCODE_CONFIG_DIR = userConfigDir
2026-04-03 17:45:02 +09:00
// when
2026-04-10 15:53:25 +09:00
const { loadPluginConfig } = await importFreshPluginConfigModule ( )
2026-04-03 17:45:02 +09:00
const config = loadPluginConfig ( projectDir , { } )
// then
expect ( config . mcp_env_allowlist ) . toEqual ( [ "USER_ONLY_TOKEN" ] )
} )
2026-04-04 01:32:17 +09:00
2026-04-10 15:53:25 +09:00
it ( "should ignore edits to the renamed legacy backup after migration" , async ( ) = > {
2026-04-04 01:32:17 +09:00
// given
const rootDir = mkdtempSync ( join ( tmpdir ( ) , "omo-plugin-config-legacy-" ) )
const userConfigDir = join ( rootDir , "user-config" )
const projectDir = join ( rootDir , "project" )
const projectConfigDir = join ( projectDir , ".opencode" )
const legacyConfigPath = join ( projectConfigDir , "oh-my-opencode.jsonc" )
const backupConfigPath = ` ${ legacyConfigPath } .bak `
const canonicalConfigPath = join ( projectConfigDir , "oh-my-openagent.jsonc" )
tempDirs . push ( rootDir )
mkdirSync ( userConfigDir , { recursive : true } )
mkdirSync ( projectConfigDir , { recursive : true } )
2026-04-24 16:17:44 +09:00
writeFileSync ( legacyConfigPath , JSON . stringify ( { agents : { oracle : { model : "openai/gpt-5.5" } } } ) )
2026-04-04 01:32:17 +09:00
2026-04-10 15:53:25 +09:00
process . env . OPENCODE_CONFIG_DIR = userConfigDir
2026-04-04 01:32:17 +09:00
// when
2026-04-10 15:53:25 +09:00
const { loadPluginConfig } = await importFreshPluginConfigModule ( )
2026-04-04 01:32:17 +09:00
loadPluginConfig ( projectDir , { } )
writeFileSync ( backupConfigPath , JSON . stringify ( { agents : { oracle : { model : "openai/gpt-5-nano" } } } ) )
const reloadedConfig = loadPluginConfig ( projectDir , { } )
// then
expect ( existsSync ( legacyConfigPath ) ) . toBe ( false )
expect ( existsSync ( backupConfigPath ) ) . toBe ( true )
2026-04-24 16:17:44 +09:00
expect ( readFileSync ( canonicalConfigPath , "utf-8" ) ) . toContain ( '"openai/gpt-5.5"' )
expect ( reloadedConfig . agents ? . oracle ? . model ) . toBe ( "openai/gpt-5.5" )
2026-04-04 01:32:17 +09:00
} )
2026-04-04 14:33:52 +09:00
2026-04-10 15:53:25 +09:00
it ( "should still load config from legacy path when migration fails" , async ( ) = > {
2026-04-05 14:26:54 +09:00
// given - legacy config exists but canonical path is not writable
const rootDir = mkdtempSync ( join ( tmpdir ( ) , "omo-plugin-config-fail-" ) )
const userConfigDir = join ( rootDir , "user-config" )
const projectDir = join ( rootDir , "project" )
const projectConfigDir = join ( projectDir , ".opencode" )
const legacyConfigPath = join ( projectConfigDir , "oh-my-opencode.json" )
tempDirs . push ( rootDir )
mkdirSync ( userConfigDir , { recursive : true } )
mkdirSync ( projectConfigDir , { recursive : true } )
2026-04-24 16:17:44 +09:00
writeFileSync ( legacyConfigPath , JSON . stringify ( { agents : { oracle : { model : "openai/gpt-5.5" } } } ) )
2026-04-05 14:26:54 +09:00
// Make the directory read-only so migration write fails
// (simulates Windows file lock / permission issues)
if ( process . platform !== "win32" ) {
chmodSync ( projectConfigDir , 0 o555 )
}
2026-04-10 15:53:25 +09:00
process . env . OPENCODE_CONFIG_DIR = userConfigDir
2026-04-05 14:26:54 +09:00
// when
let config : OhMyOpenCodeConfig
try {
2026-04-10 15:53:25 +09:00
const fresh = await importFreshPluginConfigModule ( )
config = fresh . loadPluginConfig ( projectDir , { } )
2026-04-05 14:26:54 +09:00
} finally {
// Restore permissions for cleanup
if ( process . platform !== "win32" ) {
chmodSync ( projectConfigDir , 0 o755 )
}
}
// then - should still load the config from legacy path
2026-04-24 16:17:44 +09:00
expect ( config . agents ? . oracle ? . model ) . toBe ( "openai/gpt-5.5" )
2026-04-05 14:26:54 +09:00
} )
2026-04-10 15:53:25 +09:00
it ( "should load migrated legacy project config on the first load" , async ( ) = > {
2026-04-04 14:33:52 +09:00
// given
const rootDir = mkdtempSync ( join ( tmpdir ( ) , "omo-plugin-config-first-load-" ) )
const userConfigDir = join ( rootDir , "user-config" )
const projectDir = join ( rootDir , "project" )
const projectConfigDir = join ( projectDir , ".opencode" )
const legacyConfigPath = join ( projectConfigDir , "oh-my-opencode.jsonc" )
const canonicalConfigPath = join ( projectConfigDir , "oh-my-openagent.jsonc" )
tempDirs . push ( rootDir )
mkdirSync ( userConfigDir , { recursive : true } )
mkdirSync ( projectConfigDir , { recursive : true } )
2026-04-24 16:17:44 +09:00
writeFileSync ( legacyConfigPath , JSON . stringify ( { agents : { oracle : { model : "openai/gpt-5.5" } } } ) )
2026-04-04 14:33:52 +09:00
2026-04-10 15:53:25 +09:00
process . env . OPENCODE_CONFIG_DIR = userConfigDir
2026-04-04 14:33:52 +09:00
// when
2026-04-10 15:53:25 +09:00
const { loadPluginConfig } = await importFreshPluginConfigModule ( )
2026-04-04 14:33:52 +09:00
const config = loadPluginConfig ( projectDir , { } )
// then
expect ( existsSync ( legacyConfigPath ) ) . toBe ( false )
expect ( existsSync ( canonicalConfigPath ) ) . toBe ( true )
2026-04-24 16:17:44 +09:00
expect ( config . agents ? . oracle ? . model ) . toBe ( "openai/gpt-5.5" )
2026-04-04 14:33:52 +09:00
} )
2026-04-12 18:18:11 +09:00
it ( "should preserve explicit user git_master settings when project config omits git_master" , async ( ) = > {
// given
const rootDir = mkdtempSync ( join ( tmpdir ( ) , "omo-plugin-config-git-master-user-" ) )
const userConfigDir = join ( rootDir , "user-config" )
const projectDir = join ( rootDir , "project" )
const projectConfigDir = join ( projectDir , ".opencode" )
tempDirs . push ( rootDir )
mkdirSync ( userConfigDir , { recursive : true } )
mkdirSync ( projectConfigDir , { recursive : true } )
writeFileSync (
join ( userConfigDir , "oh-my-openagent.jsonc" ) ,
JSON . stringify ( {
git_master : {
commit_footer : false ,
include_co_authored_by : false ,
} ,
} )
)
writeFileSync (
join ( projectConfigDir , "oh-my-openagent.jsonc" ) ,
JSON . stringify ( {
agents : {
2026-04-24 16:17:44 +09:00
hephaestus : { model : "openai/gpt-5.5" } ,
2026-04-12 18:18:11 +09:00
} ,
} )
)
process . env . OPENCODE_CONFIG_DIR = userConfigDir
// when
const { loadPluginConfig } = await importFreshPluginConfigModule ( )
const config = loadPluginConfig ( projectDir , { } )
// then
expect ( config . git_master ) . toEqual ( {
commit_footer : false ,
include_co_authored_by : false ,
git_env_prefix : "GIT_MASTER=1" ,
} )
} )
it ( "should merge explicit git_master keys from user and project configs" , async ( ) = > {
// given
const rootDir = mkdtempSync ( join ( tmpdir ( ) , "omo-plugin-config-git-master-merge-" ) )
const userConfigDir = join ( rootDir , "user-config" )
const projectDir = join ( rootDir , "project" )
const projectConfigDir = join ( projectDir , ".opencode" )
tempDirs . push ( rootDir )
mkdirSync ( userConfigDir , { recursive : true } )
mkdirSync ( projectConfigDir , { recursive : true } )
writeFileSync (
join ( userConfigDir , "oh-my-openagent.jsonc" ) ,
JSON . stringify ( {
git_master : {
commit_footer : false ,
include_co_authored_by : false ,
} ,
} )
)
writeFileSync (
join ( projectConfigDir , "oh-my-openagent.jsonc" ) ,
JSON . stringify ( {
git_master : {
commit_footer : true ,
} ,
} )
)
process . env . OPENCODE_CONFIG_DIR = userConfigDir
// when
const { loadPluginConfig } = await importFreshPluginConfigModule ( )
const config = loadPluginConfig ( projectDir , { } )
// then
expect ( config . git_master ) . toEqual ( {
commit_footer : true ,
include_co_authored_by : false ,
git_env_prefix : "GIT_MASTER=1" ,
} )
} )
2026-04-28 10:44:53 +09:00
describe ( "team_mode.tmux_visualization" , ( ) = > {
it ( "#given canonical user config enables team_mode and legacy config also exists #when loadPluginConfig runs #then tmux_visualization remains false" , async ( ) = > {
// given
const { userConfigDir , projectDir } = createLoadPluginConfigTestContext ( "omo-plugin-config-team-mode-user-" )
writeJsonFile ( join ( userConfigDir , "oh-my-openagent.json" ) , {
team_mode : {
enabled : true ,
} ,
} )
writeJsonFile ( join ( userConfigDir , "oh-my-opencode.json" ) , {
agents : {
oracle : {
model : "openai/gpt-5.4" ,
} ,
} ,
} )
process . env . OPENCODE_CONFIG_DIR = userConfigDir
// when
const { loadPluginConfig } = await importFreshPluginConfigModule ( )
const config = loadPluginConfig ( projectDir , { } )
// then
expect ( config . team_mode ? . enabled ) . toBe ( true )
expect ( config . team_mode ? . tmux_visualization ) . toBe ( false )
} )
it ( "#given canonical user config lacks team_mode and legacy config only enables team_mode #when loadPluginConfig runs #then canonical config wins and tmux_visualization stays effectively false" , async ( ) = > {
// given
const { userConfigDir , projectDir } = createLoadPluginConfigTestContext ( "omo-plugin-config-team-mode-legacy-" )
writeJsonFile ( join ( userConfigDir , "oh-my-openagent.json" ) , {
hashline_edit : true ,
} )
writeJsonFile ( join ( userConfigDir , "oh-my-opencode.json" ) , {
team_mode : {
enabled : true ,
} ,
} )
process . env . OPENCODE_CONFIG_DIR = userConfigDir
// when
const { loadPluginConfig } = await importFreshPluginConfigModule ( )
const config = loadPluginConfig ( projectDir , { } )
// then
expect ( config . team_mode ) . toBeUndefined ( )
expect ( config . team_mode ? . tmux_visualization ? ? false ) . toBe ( false )
} )
it ( "#given canonical user config lacks team_mode and legacy config sets tmux_visualization=true #when loadPluginConfig runs #then legacy team_mode is not promoted into the loaded config" , async ( ) = > {
// given
const { userConfigDir , projectDir } = createLoadPluginConfigTestContext ( "omo-plugin-config-team-mode-visualization-" )
writeJsonFile ( join ( userConfigDir , "oh-my-openagent.json" ) , {
hashline_edit : true ,
} )
writeJsonFile ( join ( userConfigDir , "oh-my-opencode.json" ) , {
team_mode : {
enabled : true ,
tmux_visualization : true ,
} ,
} )
process . env . OPENCODE_CONFIG_DIR = userConfigDir
// when
const { loadPluginConfig } = await importFreshPluginConfigModule ( )
const config = loadPluginConfig ( projectDir , { } )
// then
// This proves a concurrent canonical file suppresses the legacy team_mode subtree entirely.
expect ( config . team_mode ) . toBeUndefined ( )
} )
} )
2026-05-04 19:25:07 +09:00
it ( "should merge configs from ancestor directories with closer winning" , async ( ) = > {
// given
const rootDir = mkdtempSync ( join ( tmpdir ( ) , "omo-plugin-config-walk-" ) )
const userConfigDir = join ( rootDir , "user-config" )
const homeDir = join ( rootDir , "home" )
const workDir = join ( homeDir , "work" )
const projectDir = join ( workDir , "project" )
tempDirs . push ( rootDir )
mkdirSync ( userConfigDir , { recursive : true } )
mkdirSync ( join ( homeDir , ".opencode" ) , { recursive : true } )
mkdirSync ( join ( workDir , ".opencode" ) , { recursive : true } )
mkdirSync ( join ( projectDir , ".opencode" ) , { recursive : true } )
writeFileSync (
join ( userConfigDir , "oh-my-openagent.jsonc" ) ,
JSON . stringify ( { agents : { oracle : { model : "user/model" } } } )
)
writeFileSync (
join ( homeDir , ".opencode" , "oh-my-openagent.jsonc" ) ,
JSON . stringify ( { agents : { oracle : { model : "home/model" } } } )
)
writeFileSync (
join ( workDir , ".opencode" , "oh-my-openagent.jsonc" ) ,
JSON . stringify ( { agents : { oracle : { model : "work/model" } } } )
)
writeFileSync (
join ( projectDir , ".opencode" , "oh-my-openagent.jsonc" ) ,
JSON . stringify ( { agents : { oracle : { model : "project/model" } } } )
)
process . env . OPENCODE_CONFIG_DIR = userConfigDir
process . env . HOME = homeDir
// when
const { loadPluginConfig } = await importFreshPluginConfigModule ( )
const config = loadPluginConfig ( projectDir , { } )
// then
expect ( config . agents ? . oracle ? . model ) . toBe ( "project/model" )
} )
it ( "should layer ancestor configs so each contributes fields not overridden by closer ones" , async ( ) = > {
// given
const rootDir = mkdtempSync ( join ( tmpdir ( ) , "omo-plugin-config-walk-layer-" ) )
const userConfigDir = join ( rootDir , "user-config" )
const homeDir = join ( rootDir , "home" )
const workDir = join ( homeDir , "work" )
const projectDir = join ( workDir , "project" )
tempDirs . push ( rootDir )
mkdirSync ( userConfigDir , { recursive : true } )
mkdirSync ( join ( homeDir , ".opencode" ) , { recursive : true } )
mkdirSync ( join ( workDir , ".opencode" ) , { recursive : true } )
mkdirSync ( join ( projectDir , ".opencode" ) , { recursive : true } )
writeFileSync ( join ( userConfigDir , "oh-my-openagent.jsonc" ) , "{}" )
writeFileSync (
join ( homeDir , ".opencode" , "oh-my-openagent.jsonc" ) ,
JSON . stringify ( { agents : { oracle : { model : "home/oracle" } } } )
)
writeFileSync (
join ( workDir , ".opencode" , "oh-my-openagent.jsonc" ) ,
JSON . stringify ( { agents : { hephaestus : { model : "work/hephaestus" } } } )
)
writeFileSync (
join ( projectDir , ".opencode" , "oh-my-openagent.jsonc" ) ,
JSON . stringify ( { agents : { sisyphus : { model : "project/sisyphus" } } } )
)
process . env . OPENCODE_CONFIG_DIR = userConfigDir
process . env . HOME = homeDir
// when
const { loadPluginConfig } = await importFreshPluginConfigModule ( )
const config = loadPluginConfig ( projectDir , { } )
// then - each level contributes a non-conflicting field
expect ( config . agents ? . oracle ? . model ) . toBe ( "home/oracle" )
expect ( config . agents ? . hephaestus ? . model ) . toBe ( "work/hephaestus" )
expect ( config . agents ? . sisyphus ? . model ) . toBe ( "project/sisyphus" )
} )
it ( "should preserve mcp_env_allowlist as user-only when ancestors set their own allowlists" , async ( ) = > {
// given
const rootDir = mkdtempSync ( join ( tmpdir ( ) , "omo-plugin-config-walk-allowlist-" ) )
const userConfigDir = join ( rootDir , "user-config" )
const homeDir = join ( rootDir , "home" )
const workDir = join ( homeDir , "work" )
const projectDir = join ( workDir , "project" )
tempDirs . push ( rootDir )
mkdirSync ( userConfigDir , { recursive : true } )
mkdirSync ( join ( homeDir , ".opencode" ) , { recursive : true } )
mkdirSync ( join ( workDir , ".opencode" ) , { recursive : true } )
mkdirSync ( join ( projectDir , ".opencode" ) , { recursive : true } )
writeFileSync (
join ( userConfigDir , "oh-my-openagent.jsonc" ) ,
JSON . stringify ( { mcp_env_allowlist : [ "USER_ONLY_TOKEN" ] } )
)
writeFileSync (
join ( homeDir , ".opencode" , "oh-my-openagent.jsonc" ) ,
JSON . stringify ( { mcp_env_allowlist : [ "HOME_TOKEN" ] } )
)
writeFileSync (
join ( workDir , ".opencode" , "oh-my-openagent.jsonc" ) ,
JSON . stringify ( { mcp_env_allowlist : [ "WORK_TOKEN" ] } )
)
writeFileSync (
join ( projectDir , ".opencode" , "oh-my-openagent.jsonc" ) ,
JSON . stringify ( { mcp_env_allowlist : [ "PROJECT_TOKEN" ] } )
)
process . env . OPENCODE_CONFIG_DIR = userConfigDir
process . env . HOME = homeDir
// when
const { loadPluginConfig } = await importFreshPluginConfigModule ( )
const config = loadPluginConfig ( projectDir , { } )
// then - only the canonical user config can extend the allowlist
expect ( config . mcp_env_allowlist ) . toEqual ( [ "USER_ONLY_TOKEN" ] )
} )
it ( "should stop walking at $HOME and ignore configs above it" , async ( ) = > {
// given
const rootDir = mkdtempSync ( join ( tmpdir ( ) , "omo-plugin-config-walk-stop-" ) )
const userConfigDir = join ( rootDir , "user-config" )
const aboveHomeDir = join ( rootDir , "above-home" )
const homeDir = join ( aboveHomeDir , "home" )
const projectDir = join ( homeDir , "project" )
tempDirs . push ( rootDir )
mkdirSync ( userConfigDir , { recursive : true } )
mkdirSync ( join ( aboveHomeDir , ".opencode" ) , { recursive : true } )
mkdirSync ( join ( homeDir , ".opencode" ) , { recursive : true } )
mkdirSync ( join ( projectDir , ".opencode" ) , { recursive : true } )
writeFileSync ( join ( userConfigDir , "oh-my-openagent.jsonc" ) , "{}" )
writeFileSync (
join ( aboveHomeDir , ".opencode" , "oh-my-openagent.jsonc" ) ,
JSON . stringify ( { agents : { oracle : { model : "above-home/leak" } } } )
)
writeFileSync (
join ( homeDir , ".opencode" , "oh-my-openagent.jsonc" ) ,
JSON . stringify ( { agents : { hephaestus : { model : "home/wins" } } } )
)
writeFileSync ( join ( projectDir , ".opencode" , "oh-my-openagent.jsonc" ) , "{}" )
process . env . OPENCODE_CONFIG_DIR = userConfigDir
process . env . HOME = homeDir
// when
const { loadPluginConfig } = await importFreshPluginConfigModule ( )
const config = loadPluginConfig ( projectDir , { } )
// then - $HOME's config applies, but the directory above it does NOT
expect ( config . agents ? . hephaestus ? . model ) . toBe ( "home/wins" )
expect ( config . agents ? . oracle ) . toBeUndefined ( )
} )
2026-05-04 19:36:23 +09:00
it ( "should not walk above the start directory when start is outside $HOME" , async ( ) = > {
// given
const rootDir = mkdtempSync ( join ( tmpdir ( ) , "omo-plugin-config-walk-outside-" ) )
const userConfigDir = join ( rootDir , "user-config" )
const homeDir = join ( rootDir , "home" )
const outsideHomeRoot = join ( rootDir , "outside-home" )
const projectDir = join ( outsideHomeRoot , "proj" )
tempDirs . push ( rootDir )
mkdirSync ( userConfigDir , { recursive : true } )
mkdirSync ( homeDir , { recursive : true } )
mkdirSync ( join ( outsideHomeRoot , ".opencode" ) , { recursive : true } )
mkdirSync ( join ( projectDir , ".opencode" ) , { recursive : true } )
writeFileSync ( join ( userConfigDir , "oh-my-openagent.jsonc" ) , "{}" )
writeFileSync (
join ( outsideHomeRoot , ".opencode" , "oh-my-openagent.jsonc" ) ,
JSON . stringify ( { agents : { oracle : { model : "outside-home/leak" } } } )
)
writeFileSync (
join ( projectDir , ".opencode" , "oh-my-openagent.jsonc" ) ,
JSON . stringify ( { agents : { hephaestus : { model : "project/wins" } } } )
)
process . env . OPENCODE_CONFIG_DIR = userConfigDir
process . env . HOME = homeDir
// when
const { loadPluginConfig } = await importFreshPluginConfigModule ( )
const config = loadPluginConfig ( projectDir , { } )
// then - project loads, but the parent above it (outside $HOME) is not walked into
expect ( config . agents ? . hephaestus ? . model ) . toBe ( "project/wins" )
expect ( config . agents ? . oracle ) . toBeUndefined ( )
} )
it ( "should merge git_master overrides across ancestors with closer winning" , async ( ) = > {
// given
const rootDir = mkdtempSync ( join ( tmpdir ( ) , "omo-plugin-config-walk-git-master-" ) )
const userConfigDir = join ( rootDir , "user-config" )
const homeDir = join ( rootDir , "home" )
const workDir = join ( homeDir , "work" )
const projectDir = join ( workDir , "project" )
tempDirs . push ( rootDir )
mkdirSync ( userConfigDir , { recursive : true } )
mkdirSync ( join ( homeDir , ".opencode" ) , { recursive : true } )
mkdirSync ( join ( workDir , ".opencode" ) , { recursive : true } )
mkdirSync ( join ( projectDir , ".opencode" ) , { recursive : true } )
writeFileSync ( join ( userConfigDir , "oh-my-openagent.jsonc" ) , "{}" )
writeFileSync (
join ( homeDir , ".opencode" , "oh-my-openagent.jsonc" ) ,
JSON . stringify ( {
git_master : {
commit_footer : false ,
include_co_authored_by : false ,
git_env_prefix : "HOME=1" ,
} ,
} )
)
writeFileSync (
join ( workDir , ".opencode" , "oh-my-openagent.jsonc" ) ,
JSON . stringify ( {
git_master : {
include_co_authored_by : true ,
} ,
} )
)
writeFileSync (
join ( projectDir , ".opencode" , "oh-my-openagent.jsonc" ) ,
JSON . stringify ( {
git_master : {
commit_footer : true ,
} ,
} )
)
process . env . OPENCODE_CONFIG_DIR = userConfigDir
process . env . HOME = homeDir
// when
const { loadPluginConfig } = await importFreshPluginConfigModule ( )
const config = loadPluginConfig ( projectDir , { } )
// then project's commit_footer wins, work's include_co_authored_by wins,
// home's git_env_prefix is preserved since nobody else set it
expect ( config . git_master ) . toEqual ( {
commit_footer : true ,
include_co_authored_by : true ,
git_env_prefix : "HOME=1" ,
} )
} )
it ( "should resolve agent_definitions relative to each ancestor's own .opencode directory" , async ( ) = > {
// given
const rootDir = mkdtempSync ( join ( tmpdir ( ) , "omo-plugin-config-walk-agent-defs-" ) )
const userConfigDir = join ( rootDir , "user-config" )
const homeDir = join ( rootDir , "home" )
const workDir = join ( homeDir , "work" )
const projectDir = join ( workDir , "project" )
const workDefRelativePath = "./work-agent.md"
const projectDefRelativePath = "./project-agent.md"
tempDirs . push ( rootDir )
mkdirSync ( userConfigDir , { recursive : true } )
mkdirSync ( join ( workDir , ".opencode" ) , { recursive : true } )
mkdirSync ( join ( projectDir , ".opencode" ) , { recursive : true } )
writeFileSync ( join ( userConfigDir , "oh-my-openagent.jsonc" ) , "{}" )
writeFileSync (
join ( workDir , ".opencode" , "oh-my-openagent.jsonc" ) ,
JSON . stringify ( { agent_definitions : [ workDefRelativePath ] } )
)
writeFileSync (
join ( projectDir , ".opencode" , "oh-my-openagent.jsonc" ) ,
JSON . stringify ( { agent_definitions : [ projectDefRelativePath ] } )
)
process . env . OPENCODE_CONFIG_DIR = userConfigDir
process . env . HOME = homeDir
// when
const { loadPluginConfig } = await importFreshPluginConfigModule ( )
const config = loadPluginConfig ( projectDir , { } )
// then each ancestor's relative path resolves against its own .opencode/
expect ( config . agent_definitions ) . toContain ( join ( realpathSync ( workDir ) , ".opencode" , "work-agent.md" ) )
expect ( config . agent_definitions ) . toContain ( join ( realpathSync ( projectDir ) , ".opencode" , "project-agent.md" ) )
} )
2026-05-04 19:25:07 +09:00
it ( "should migrate legacy basenames found in ancestor directories" , async ( ) = > {
// given
const rootDir = mkdtempSync ( join ( tmpdir ( ) , "omo-plugin-config-walk-legacy-" ) )
const userConfigDir = join ( rootDir , "user-config" )
const homeDir = join ( rootDir , "home" )
const workDir = join ( homeDir , "work" )
const projectDir = join ( workDir , "project" )
const ancestorLegacyPath = join ( workDir , ".opencode" , "oh-my-opencode.jsonc" )
const ancestorCanonicalPath = join ( workDir , ".opencode" , "oh-my-openagent.jsonc" )
tempDirs . push ( rootDir )
mkdirSync ( userConfigDir , { recursive : true } )
mkdirSync ( join ( homeDir , ".opencode" ) , { recursive : true } )
mkdirSync ( join ( workDir , ".opencode" ) , { recursive : true } )
mkdirSync ( join ( projectDir , ".opencode" ) , { recursive : true } )
writeFileSync ( join ( userConfigDir , "oh-my-openagent.jsonc" ) , "{}" )
writeFileSync (
ancestorLegacyPath ,
JSON . stringify ( { agents : { oracle : { model : "ancestor-legacy/model" } } } )
)
process . env . OPENCODE_CONFIG_DIR = userConfigDir
process . env . HOME = homeDir
// when
const { loadPluginConfig } = await importFreshPluginConfigModule ( )
const config = loadPluginConfig ( projectDir , { } )
// then
expect ( existsSync ( ancestorLegacyPath ) ) . toBe ( false )
expect ( existsSync ( ancestorCanonicalPath ) ) . toBe ( true )
expect ( config . agents ? . oracle ? . model ) . toBe ( "ancestor-legacy/model" )
} )
2026-04-03 17:45:02 +09:00
} )