fix(opencode): address issues found during OpenCode integration review

- Fix MCP server not removed from opencode.json when unchecked in edit modal
- Fix Windows atomic write failure when opencode.json already exists
- Fix i18n keys mismatch in OpenCodeFormFields (use opencode.* namespace)
- Fix unit test missing apps.opencode field assertion
This commit is contained in:
Jason
2026-01-15 19:07:49 +08:00
parent 36d6d48002
commit de3a22535d
12 changed files with 53 additions and 62 deletions
+6 -1
View File
@@ -587,7 +587,12 @@ impl MultiAppConfig {
log::info!("检测到已存在配置文件且 Prompt 列表为空,将尝试从现有提示词文件自动导入");
let mut imported = false;
for app in [AppType::Claude, AppType::Codex, AppType::Gemini, AppType::OpenCode] {
for app in [
AppType::Claude,
AppType::Codex,
AppType::Gemini,
AppType::OpenCode,
] {
// 复用已有的单应用导入逻辑
if Self::auto_import_prompt_if_exists(self, app)? {
imported = true;
+7 -13
View File
@@ -68,10 +68,7 @@ pub fn convert_to_opencode_format(spec: &Value) -> Result<Value, AppError> {
.as_object()
.ok_or_else(|| AppError::McpValidation("MCP spec must be a JSON object".into()))?;
let typ = obj
.get("type")
.and_then(|v| v.as_str())
.unwrap_or("stdio");
let typ = obj.get("type").and_then(|v| v.as_str()).unwrap_or("stdio");
let mut result = serde_json::Map::new();
@@ -81,10 +78,7 @@ pub fn convert_to_opencode_format(spec: &Value) -> Result<Value, AppError> {
result.insert("type".into(), json!("local"));
// Merge command and args into a single array
let cmd = obj
.get("command")
.and_then(|v| v.as_str())
.unwrap_or("");
let cmd = obj.get("command").and_then(|v| v.as_str()).unwrap_or("");
let mut command_arr = vec![json!(cmd)];
if let Some(args) = obj.get("args").and_then(|v| v.as_array()) {
@@ -149,10 +143,7 @@ pub fn convert_from_opencode_format(spec: &Value) -> Result<Value, AppError> {
.as_object()
.ok_or_else(|| AppError::McpValidation("OpenCode MCP spec must be a JSON object".into()))?;
let typ = obj
.get("type")
.and_then(|v| v.as_str())
.unwrap_or("local");
let typ = obj.get("type").and_then(|v| v.as_str()).unwrap_or("local");
let mut result = serde_json::Map::new();
@@ -360,7 +351,10 @@ mod tests {
assert_eq!(result["type"], "local");
assert_eq!(result["command"][0], "npx");
assert_eq!(result["command"][1], "-y");
assert_eq!(result["command"][2], "@modelcontextprotocol/server-filesystem");
assert_eq!(
result["command"][2],
"@modelcontextprotocol/server-filesystem"
);
assert_eq!(result["environment"]["HOME"], "/Users/test");
assert_eq!(result["enabled"], true);
}
+11 -29
View File
@@ -21,6 +21,7 @@
//! }
//! ```
use crate::config::write_json_file;
use crate::error::AppError;
use crate::provider::{OpenCodeModel, OpenCodeProviderConfig, OpenCodeProviderOptions};
use crate::settings::get_opencode_override_dir;
@@ -100,21 +101,8 @@ pub fn read_opencode_config() -> Result<Value, AppError> {
/// 使用临时文件 + 重命名确保原子性
pub fn write_opencode_config(config: &Value) -> Result<(), AppError> {
let path = get_opencode_config_path();
// Ensure directory exists
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).map_err(|e| AppError::io(parent, e))?;
}
// Write to temporary file first
let temp_path = path.with_extension("json.tmp");
let content =
serde_json::to_string_pretty(config).map_err(|e| AppError::JsonSerialize { source: e })?;
std::fs::write(&temp_path, &content).map_err(|e| AppError::io(&temp_path, e))?;
// Atomic rename
std::fs::rename(&temp_path, &path).map_err(|e| AppError::io(&path, e))?;
// 复用统一的原子写入逻辑(兼容 Windows 上目标文件已存在的情况)
write_json_file(&path, config)?;
log::debug!("OpenCode config written to {:?}", path);
Ok(())
@@ -147,7 +135,10 @@ pub fn set_provider(id: &str, config: Value) -> Result<(), AppError> {
full_config["provider"] = json!({});
}
if let Some(providers) = full_config.get_mut("provider").and_then(|v| v.as_object_mut()) {
if let Some(providers) = full_config
.get_mut("provider")
.and_then(|v| v.as_object_mut())
{
providers.insert(id.to_string(), config);
}
@@ -205,8 +196,7 @@ pub fn get_typed_provider(id: &str) -> Result<Option<OpenCodeProviderConfig>, Ap
/// 设置供应商配置(类型化)
pub fn set_typed_provider(id: &str, config: &OpenCodeProviderConfig) -> Result<(), AppError> {
let value =
serde_json::to_value(config).map_err(|e| AppError::JsonSerialize { source: e })?;
let value = serde_json::to_value(config).map_err(|e| AppError::JsonSerialize { source: e })?;
set_provider(id, value)
}
@@ -302,15 +292,7 @@ pub fn create_provider_config(
let model_map: HashMap<String, OpenCodeModel> = models
.into_iter()
.map(|(id, name)| {
(
id,
OpenCodeModel {
name,
limit: None,
},
)
})
.map(|(id, name)| (id, OpenCodeModel { name, limit: None }))
.collect();
OpenCodeProviderConfig {
@@ -350,11 +332,11 @@ pub fn validate_provider_config(config: &OpenCodeProviderConfig) -> Result<(), A
pub fn provider_to_opencode_config(
settings_config: &Value,
) -> Result<OpenCodeProviderConfig, AppError> {
serde_json::from_value(settings_config.clone()).map_err(|e| AppError::JsonSerialize { source: e })
serde_json::from_value(settings_config.clone())
.map_err(|e| AppError::JsonSerialize { source: e })
}
/// 将 OpenCode 配置转换为通用 Provider settings_config
pub fn opencode_config_to_provider(config: &OpenCodeProviderConfig) -> Result<Value, AppError> {
serde_json::to_value(config).map_err(|e| AppError::JsonSerialize { source: e })
}
-1
View File
@@ -571,4 +571,3 @@ impl OpenCodeProviderConfig {
.unwrap_or(false)
}
}
+8 -1
View File
@@ -37,6 +37,9 @@ impl McpService {
if prev_apps.gemini && !server.apps.gemini {
Self::remove_server_from_app(state, &server.id, &AppType::Gemini)?;
}
if prev_apps.opencode && !server.apps.opencode {
Self::remove_server_from_app(state, &server.id, &AppType::OpenCode)?;
}
// 同步到各个启用的应用
Self::sync_server_to_apps(state, &server)?;
@@ -114,7 +117,11 @@ impl McpService {
mcp::sync_single_server_to_gemini(&Default::default(), &server.id, &server.server)?;
}
AppType::OpenCode => {
mcp::sync_single_server_to_opencode(&Default::default(), &server.id, &server.server)?;
mcp::sync_single_server_to_opencode(
&Default::default(),
&server.id,
&server.server,
)?;
}
}
Ok(())
+5 -5
View File
@@ -132,10 +132,7 @@ pub(crate) fn write_live_snapshot(app_type: &AppType, provider: &Provider) -> Re
match opencode_config_result {
Ok(config) => {
opencode_config::set_typed_provider(&provider.id, &config)?;
log::info!(
"OpenCode provider '{}' written to live config",
provider.id
);
log::info!("OpenCode provider '{}' written to live config", provider.id);
}
Err(e) => {
log::warn!(
@@ -505,7 +502,10 @@ pub fn import_opencode_providers_from_live(state: &AppState) -> Result<usize, Ap
for (id, config) in providers {
// Skip if already exists in database
if existing.contains_key(&id) {
log::debug!("OpenCode provider '{}' already exists in database, skipping", id);
log::debug!(
"OpenCode provider '{}' already exists in database, skipping",
id
);
continue;
}
+1 -1
View File
@@ -190,7 +190,7 @@ impl StreamCheckService {
return Err(AppError::localized(
"opencode_no_stream_check",
"OpenCode 暂不支持健康检查",
"OpenCode does not support health check yet"
"OpenCode does not support health check yet",
));
}
};
@@ -93,14 +93,14 @@ export function OpenCodeFormFields({
{/* NPM Package Selector */}
<div className="space-y-2">
<FormLabel htmlFor="opencode-npm">
{t("provider.form.opencode.npmPackage", {
{t("opencode.npmPackage", {
defaultValue: "AI SDK Package",
})}
</FormLabel>
<Select value={npm} onValueChange={onNpmChange}>
<SelectTrigger id="opencode-npm">
<SelectValue
placeholder={t("provider.form.opencode.selectPackage", {
placeholder={t("opencode.selectPackage", {
defaultValue: "Select a package",
})}
/>
@@ -114,7 +114,7 @@ export function OpenCodeFormFields({
</SelectContent>
</Select>
<p className="text-xs text-muted-foreground">
{t("provider.form.opencode.npmPackageHint", {
{t("opencode.npmPackageHint", {
defaultValue:
"Select the AI SDK package that matches your provider.",
})}
@@ -134,7 +134,7 @@ export function OpenCodeFormFields({
{npm === "@ai-sdk/openai-compatible" && (
<div className="space-y-2">
<FormLabel htmlFor="opencode-baseurl">
{t("provider.form.opencode.baseUrl", { defaultValue: "Base URL" })}
{t("opencode.baseUrl", { defaultValue: "Base URL" })}
</FormLabel>
<Input
id="opencode-baseurl"
@@ -143,7 +143,7 @@ export function OpenCodeFormFields({
placeholder="https://api.example.com/v1"
/>
<p className="text-xs text-muted-foreground">
{t("provider.form.opencode.baseUrlHint", {
{t("opencode.baseUrlHint", {
defaultValue:
"The base URL for OpenAI-compatible API endpoints.",
})}
@@ -155,7 +155,7 @@ export function OpenCodeFormFields({
<div className="space-y-3">
<div className="flex items-center justify-between">
<FormLabel>
{t("provider.form.opencode.models", { defaultValue: "Models" })}
{t("opencode.models", { defaultValue: "Models" })}
</FormLabel>
<Button
type="button"
@@ -165,13 +165,13 @@ export function OpenCodeFormFields({
className="h-7 gap-1"
>
<Plus className="h-3.5 w-3.5" />
{t("provider.form.opencode.addModel", { defaultValue: "Add" })}
{t("opencode.addModel", { defaultValue: "Add" })}
</Button>
</div>
{Object.keys(models).length === 0 ? (
<p className="text-sm text-muted-foreground py-2">
{t("provider.form.opencode.noModels", {
{t("opencode.noModels", {
defaultValue: "No models configured. Click Add to add a model.",
})}
</p>
@@ -182,7 +182,7 @@ export function OpenCodeFormFields({
<Input
value={key}
onChange={(e) => handleModelIdChange(key, e.target.value)}
placeholder={t("provider.form.opencode.modelId", {
placeholder={t("opencode.modelId", {
defaultValue: "Model ID",
})}
className="flex-1"
@@ -190,7 +190,7 @@ export function OpenCodeFormFields({
<Input
value={model.name}
onChange={(e) => handleModelNameChange(key, e.target.value)}
placeholder={t("provider.form.opencode.modelName", {
placeholder={t("opencode.modelName", {
defaultValue: "Display Name",
})}
className="flex-1"
@@ -210,7 +210,7 @@ export function OpenCodeFormFields({
)}
<p className="text-xs text-muted-foreground">
{t("provider.form.opencode.modelsHint", {
{t("opencode.modelsHint", {
defaultValue:
"Configure available models. Model ID is the API identifier, Display Name is shown in the UI.",
})}
+1
View File
@@ -462,6 +462,7 @@
},
"opencode": {
"npmPackage": "AI SDK Package",
"selectPackage": "Select a package",
"npmPackageHint": "Select the npm package for communicating with the AI service",
"baseUrl": "Base URL",
"baseUrlHint": "Custom API endpoint URL",
+1
View File
@@ -462,6 +462,7 @@
},
"opencode": {
"npmPackage": "AI SDK パッケージ",
"selectPackage": "パッケージを選択",
"npmPackageHint": "AI サービスとの通信に使用する npm パッケージを選択",
"baseUrl": "Base URL",
"baseUrlHint": "カスタム API エンドポイント URL",
+1
View File
@@ -462,6 +462,7 @@
},
"opencode": {
"npmPackage": "AI SDK 包",
"selectPackage": "选择一个包",
"npmPackageHint": "选择用于与 AI 服务通信的 npm 包",
"baseUrl": "Base URL",
"baseUrlHint": "自定义 API 端点地址",
+1
View File
@@ -432,6 +432,7 @@ type = "stdio"
claude: false,
codex: false,
gemini: false,
opencode: false,
});
expect(onSave).toHaveBeenCalledTimes(1);
expect(toastErrorMock).not.toHaveBeenCalled();