fix(settings): infer ark coding preset by model

This commit is contained in:
Fini 2026-06-01 03:18:15 +08:00
parent 0c9fa601ea
commit ec03f06cb6
6 changed files with 380 additions and 277 deletions

View file

@ -609,7 +609,7 @@ impl AgentSettings {
self.next_builtin_agent_id = self.next_builtin_agent_id.max(1).saturating_add(1);
self.builtin_agents.push(BuiltinAgentConfig {
id: id.clone(),
preset: infer_builtin_agent_preset(kind, &base_url),
preset: infer_builtin_agent_preset(kind, &base_url, &model),
display_name,
kind,
api_key,

View file

@ -282,19 +282,64 @@ pub fn builtin_agent_preset(key: BuiltinAgentPresetKey) -> BuiltinAgentPreset {
.unwrap_or(BUILTIN_AGENT_PRESETS[0])
}
pub fn infer_builtin_agent_preset(kind: BuiltinAgentKind, base_url: &str) -> BuiltinAgentPresetKey {
pub fn infer_builtin_agent_preset(
kind: BuiltinAgentKind,
base_url: &str,
model: &str,
) -> BuiltinAgentPresetKey {
let normalized = base_url.trim().trim_end_matches('/');
BUILTIN_AGENT_PRESETS
let model = model.trim();
let mut matches = BUILTIN_AGENT_PRESETS
.iter()
.find(|preset| {
preset.base_url.trim_end_matches('/') == normalized
|| preset
.alt_base_url
.is_some_and(|url| url.trim_end_matches('/') == normalized)
})
.map(|preset| preset.key)
.unwrap_or(match kind {
BuiltinAgentKind::Anthropic => BuiltinAgentPresetKey::Anthropic,
BuiltinAgentKind::OpenAiCompat => BuiltinAgentPresetKey::Custom,
})
.filter(|preset| preset_url_matches(**preset, normalized));
if let Some(preset) = matches
.clone()
.find(|preset| preset_kind_matches(**preset, kind) && preset.model == model)
{
return preset.key;
}
if let Some(preset) = matches.find(|preset| preset_kind_matches(**preset, kind)) {
return preset.key;
}
match kind {
BuiltinAgentKind::Anthropic => BuiltinAgentPresetKey::Anthropic,
BuiltinAgentKind::OpenAiCompat => BuiltinAgentPresetKey::Custom,
}
}
pub fn normalize_builtin_agent_preset(
saved: BuiltinAgentPresetKey,
kind: BuiltinAgentKind,
base_url: &str,
model: &str,
) -> BuiltinAgentPresetKey {
let inferred = infer_builtin_agent_preset(kind, base_url, model);
if inferred == saved {
return saved;
}
let saved_preset = builtin_agent_preset(saved);
let inferred_preset = builtin_agent_preset(inferred);
let normalized = base_url.trim().trim_end_matches('/');
let model = model.trim();
if preset_url_matches(saved_preset, normalized)
&& preset_kind_matches(saved_preset, kind)
&& inferred_preset.model == model
&& saved_preset.model != model
{
inferred
} else {
saved
}
}
fn preset_url_matches(preset: BuiltinAgentPreset, normalized: &str) -> bool {
preset.base_url.trim_end_matches('/') == normalized
|| preset
.alt_base_url
.is_some_and(|url| url.trim_end_matches('/') == normalized)
}
fn preset_kind_matches(preset: BuiltinAgentPreset, kind: BuiltinAgentKind) -> bool {
preset.kind == kind || preset.alt_kind == Some(kind)
}

View file

@ -79,8 +79,8 @@ pub use agent_settings::{
ImageGenProfile, ImageGenProvider, ImageSearchField, McpCli, McpServer, SettingsFocus,
};
pub use agent_settings_builtin_presets::{
builtin_agent_preset, infer_builtin_agent_preset, BuiltinAgentPreset, BuiltinAgentPresetKey,
BUILTIN_AGENT_PRESETS,
builtin_agent_preset, infer_builtin_agent_preset, normalize_builtin_agent_preset,
BuiltinAgentPreset, BuiltinAgentPresetKey, BUILTIN_AGENT_PRESETS,
};
pub use align::AlignAction;
pub use chat::{

View file

@ -125,6 +125,26 @@ fn add_builtin_agent_prefills_ts_provider_presets_first() {
);
}
#[test]
fn ark_coding_builtin_agent_infers_ark_coding_preset() {
let mut s = AgentSettings::default();
let id = s.add_builtin_agent_config(
"方舟CP",
"sk-test",
"ark-code-latest",
BuiltinAgentKind::Anthropic,
"https://ark.cn-beijing.volces.com/api/coding",
);
let agent = s
.builtin_agents
.iter()
.find(|agent| agent.id == id)
.expect("agent added");
assert_eq!(agent.preset, BuiltinAgentPresetKey::ArkCoding);
}
#[test]
fn pure_builtin_presets_do_not_toggle_api_format() {
let mut anthropic = BuiltinAgentConfig {

View file

@ -339,7 +339,17 @@ fn builtin_agent_from_payload(payload: BuiltinAgentPayload) -> Option<BuiltinAge
.preset
.as_deref()
.and_then(BuiltinAgentPresetKey::from_str)
.unwrap_or_else(|| op_editor_core::infer_builtin_agent_preset(kind, &payload.base_url)),
.map(|saved| {
op_editor_core::normalize_builtin_agent_preset(
saved,
kind,
&payload.base_url,
&payload.model,
)
})
.unwrap_or_else(|| {
op_editor_core::infer_builtin_agent_preset(kind, &payload.base_url, &payload.model)
}),
display_name: payload.display_name,
kind,
api_key: payload.api_key,
@ -633,264 +643,6 @@ fn str_to_locale(s: &str) -> Option<Locale> {
_ => return None,
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn connected_state_round_trips_through_payload() {
// Connect Claude (0) + Gemini (4), leave the rest off.
let mut src = EditorState::new();
src.editor_ui.agent_settings.connected = [true, false, false, false, true];
// Serialize → JSON → deserialize, the real on-disk path.
let json = serde_json::to_string(&to_payload(&src)).unwrap();
let payload: SettingsPayload = serde_json::from_str(&json).unwrap();
let mut dst = EditorState::new();
apply_payload(&mut dst, payload);
assert_eq!(
dst.editor_ui.agent_settings.connected,
[true, false, false, false, true]
);
}
#[test]
fn legacy_settings_without_connected_field_default_to_disconnected() {
// A settings.json written before the `connected` field
// existed must still load — the missing field defaults to
// all-disconnected rather than failing the parse.
let legacy = r#"{"version":1,"theme":"dark","locale":"en-US"}"#;
let payload: SettingsPayload = serde_json::from_str(legacy).unwrap();
let mut dst = EditorState::new();
apply_payload(&mut dst, payload);
assert_eq!(dst.editor_ui.agent_settings.connected, [false; 5]);
}
#[test]
fn builtin_agents_round_trip_through_payload() {
let mut src = EditorState::new();
src.editor_ui.agent_settings.add_builtin_agent_config(
"MiniMax",
"sk-test",
"MiniMax-M2.7",
BuiltinAgentKind::Anthropic,
"https://api.minimaxi.com/anthropic",
);
let json = serde_json::to_string(&to_payload(&src)).unwrap();
let payload: SettingsPayload = serde_json::from_str(&json).unwrap();
let mut dst = EditorState::new();
apply_payload(&mut dst, payload);
assert_eq!(dst.editor_ui.agent_settings.builtin_agents.len(), 1);
assert_eq!(
dst.editor_ui.agent_settings.builtin_agents[0].display_name,
"MiniMax"
);
assert_eq!(
dst.editor_ui.agent_settings.builtin_agents[0].api_key,
"sk-test"
);
assert_eq!(
dst.editor_ui.agent_settings.builtin_agents[0].preset,
BuiltinAgentPresetKey::MiniMax
);
}
#[test]
fn duplicate_builtin_agents_are_deduped_on_load() {
let settings = r#"{
"version": 1,
"builtin_agents": [
{
"id": "builtin-1",
"display_name": "MINIMAX",
"kind": "openai-compat",
"api_key": "sk-test",
"model": "MiniMax-M2.7",
"base_url": "https://api.minimaxi.com/v1",
"enabled": true
},
{
"id": "builtin-2",
"display_name": "MINIMAX",
"kind": "openai-compat",
"api_key": "sk-test",
"model": "MiniMax-M2.7",
"base_url": "https://api.minimaxi.com/v1",
"enabled": true
}
]
}"#;
let payload: SettingsPayload = serde_json::from_str(settings).unwrap();
let mut dst = EditorState::new();
apply_payload(&mut dst, payload);
assert_eq!(dst.editor_ui.agent_settings.builtin_agents.len(), 1);
assert_eq!(
dst.editor_ui.agent_settings.builtin_agents[0].id,
"builtin-1"
);
assert_eq!(dst.editor_ui.agent_settings.next_builtin_agent_id, 2);
}
#[test]
fn duplicate_auto_named_builtin_agents_are_deduped_on_load() {
let settings = r#"{
"version": 1,
"builtin_agents": [
{
"id": "builtin-5",
"display_name": "Built-in Agent 5",
"kind": "anthropic",
"api_key": "sk-test",
"model": "claude-sonnet-4-5",
"base_url": "https://api.anthropic.com",
"enabled": true
},
{
"id": "builtin-6",
"display_name": "Built-in Agent 6",
"kind": "anthropic",
"api_key": "sk-test",
"model": "claude-sonnet-4-5",
"base_url": "https://api.anthropic.com",
"enabled": true
}
]
}"#;
let payload: SettingsPayload = serde_json::from_str(settings).unwrap();
let mut dst = EditorState::new();
apply_payload(&mut dst, payload);
assert_eq!(dst.editor_ui.agent_settings.builtin_agents.len(), 1);
assert_eq!(
dst.editor_ui.agent_settings.builtin_agents[0].display_name,
"Built-in Agent 5"
);
assert_eq!(dst.editor_ui.agent_settings.next_builtin_agent_id, 6);
}
#[test]
fn acp_agents_round_trip_through_payload() {
let mut src = EditorState::new();
let mut env = std::collections::BTreeMap::new();
env.insert("ACP_TOKEN".into(), "secret".into());
src.editor_ui.agent_settings.add_acp_agent_config(
"Design Agent",
AcpConnectionType::Local,
"/usr/local/bin/design-agent",
vec!["--stdio".into()],
env,
None,
true,
);
src.editor_ui.agent_settings.add_acp_agent_config(
"Remote Agent",
AcpConnectionType::Remote,
"",
Vec::new(),
std::collections::BTreeMap::new(),
Some("ws://localhost:8100".into()),
false,
);
let json = serde_json::to_string(&to_payload(&src)).unwrap();
let payload: SettingsPayload = serde_json::from_str(&json).unwrap();
let mut dst = EditorState::new();
apply_payload(&mut dst, payload);
assert_eq!(dst.editor_ui.agent_settings.acp_agents.len(), 2);
let local = &dst.editor_ui.agent_settings.acp_agents[0];
assert_eq!(local.display_name, "Design Agent");
assert_eq!(local.connection_type, AcpConnectionType::Local);
assert_eq!(local.command, "/usr/local/bin/design-agent");
assert!(!local.connected);
assert_eq!(local.args, vec!["--stdio"]);
assert_eq!(
local.env.get("ACP_TOKEN").map(String::as_str),
Some("secret")
);
let remote = &dst.editor_ui.agent_settings.acp_agents[1];
assert_eq!(remote.connection_type, AcpConnectionType::Remote);
assert_eq!(remote.url.as_deref(), Some("ws://localhost:8100"));
assert!(!remote.enabled);
assert_eq!(dst.editor_ui.agent_settings.next_acp_agent_id, 3);
}
#[test]
fn image_generation_profiles_round_trip_through_payload() {
let mut src = EditorState::new();
let first = src.editor_ui.agent_settings.add_image_gen_profile();
let second = src.editor_ui.agent_settings.add_image_gen_profile();
let second_profile = &mut src.editor_ui.agent_settings.image_gen_profiles[1];
second_profile.name = "Gemini Image".into();
second_profile.provider = ImageGenProvider::Gemini;
second_profile.api_key = "image-key".into();
second_profile.model = "gemini-image".into();
second_profile.base_url = Some("https://images.example/v1".into());
assert!(src
.editor_ui
.agent_settings
.set_active_image_gen_profile(&second));
let json = serde_json::to_string(&to_payload(&src)).unwrap();
let payload: SettingsPayload = serde_json::from_str(&json).unwrap();
let mut dst = EditorState::new();
apply_payload(&mut dst, payload);
assert_eq!(dst.editor_ui.agent_settings.image_gen_profiles.len(), 2);
assert_eq!(
dst.editor_ui
.agent_settings
.active_image_gen_profile_id
.as_deref(),
Some(second.as_str())
);
assert_eq!(dst.editor_ui.agent_settings.image_gen_profiles[0].id, first);
assert_eq!(
dst.editor_ui.agent_settings.image_gen_profiles[1].provider,
ImageGenProvider::Gemini
);
assert_eq!(
dst.editor_ui.agent_settings.image_gen_profiles[1].base_url,
Some("https://images.example/v1".into())
);
}
#[test]
fn openverse_oauth_round_trips_through_payload() {
let mut src = EditorState::new();
src.editor_ui.agent_settings.openverse_client_id = "client-id".into();
src.editor_ui.agent_settings.openverse_client_secret = "client-secret".into();
let json = serde_json::to_string(&to_payload(&src)).unwrap();
let payload: SettingsPayload = serde_json::from_str(&json).unwrap();
let mut dst = EditorState::new();
apply_payload(&mut dst, payload);
assert_eq!(
dst.editor_ui.agent_settings.openverse_client_id,
"client-id"
);
assert_eq!(
dst.editor_ui.agent_settings.openverse_client_secret,
"client-secret"
);
}
#[test]
fn auto_update_preference_round_trips_through_payload() {
let mut src = EditorState::new();
src.editor_ui.agent_settings.auto_update_enabled = false;
let json = serde_json::to_string(&to_payload(&src)).unwrap();
let payload: SettingsPayload = serde_json::from_str(&json).unwrap();
let mut dst = EditorState::new();
apply_payload(&mut dst, payload);
assert!(!dst.editor_ui.agent_settings.auto_update_enabled);
}
}
#[path = "settings_io_tests.rs"]
mod settings_io_tests;

View file

@ -0,0 +1,286 @@
use super::*;
#[test]
fn connected_state_round_trips_through_payload() {
// Connect Claude (0) + Gemini (4), leave the rest off.
let mut src = EditorState::new();
src.editor_ui.agent_settings.connected = [true, false, false, false, true];
// Serialize → JSON → deserialize, the real on-disk path.
let json = serde_json::to_string(&to_payload(&src)).unwrap();
let payload: SettingsPayload = serde_json::from_str(&json).unwrap();
let mut dst = EditorState::new();
apply_payload(&mut dst, payload);
assert_eq!(
dst.editor_ui.agent_settings.connected,
[true, false, false, false, true]
);
}
#[test]
fn legacy_settings_without_connected_field_default_to_disconnected() {
// A settings.json written before the `connected` field
// existed must still load — the missing field defaults to
// all-disconnected rather than failing the parse.
let legacy = r#"{"version":1,"theme":"dark","locale":"en-US"}"#;
let payload: SettingsPayload = serde_json::from_str(legacy).unwrap();
let mut dst = EditorState::new();
apply_payload(&mut dst, payload);
assert_eq!(dst.editor_ui.agent_settings.connected, [false; 5]);
}
#[test]
fn builtin_agents_round_trip_through_payload() {
let mut src = EditorState::new();
src.editor_ui.agent_settings.add_builtin_agent_config(
"MiniMax",
"sk-test",
"MiniMax-M2.7",
BuiltinAgentKind::Anthropic,
"https://api.minimaxi.com/anthropic",
);
let json = serde_json::to_string(&to_payload(&src)).unwrap();
let payload: SettingsPayload = serde_json::from_str(&json).unwrap();
let mut dst = EditorState::new();
apply_payload(&mut dst, payload);
assert_eq!(dst.editor_ui.agent_settings.builtin_agents.len(), 1);
assert_eq!(
dst.editor_ui.agent_settings.builtin_agents[0].display_name,
"MiniMax"
);
assert_eq!(
dst.editor_ui.agent_settings.builtin_agents[0].api_key,
"sk-test"
);
assert_eq!(
dst.editor_ui.agent_settings.builtin_agents[0].preset,
BuiltinAgentPresetKey::MiniMax
);
}
#[test]
fn duplicate_builtin_agents_are_deduped_on_load() {
let settings = r#"{
"version": 1,
"builtin_agents": [
{
"id": "builtin-1",
"display_name": "MINIMAX",
"kind": "openai-compat",
"api_key": "sk-test",
"model": "MiniMax-M2.7",
"base_url": "https://api.minimaxi.com/v1",
"enabled": true
},
{
"id": "builtin-2",
"display_name": "MINIMAX",
"kind": "openai-compat",
"api_key": "sk-test",
"model": "MiniMax-M2.7",
"base_url": "https://api.minimaxi.com/v1",
"enabled": true
}
]
}"#;
let payload: SettingsPayload = serde_json::from_str(settings).unwrap();
let mut dst = EditorState::new();
apply_payload(&mut dst, payload);
assert_eq!(dst.editor_ui.agent_settings.builtin_agents.len(), 1);
assert_eq!(
dst.editor_ui.agent_settings.builtin_agents[0].id,
"builtin-1"
);
assert_eq!(dst.editor_ui.agent_settings.next_builtin_agent_id, 2);
}
#[test]
fn duplicate_auto_named_builtin_agents_are_deduped_on_load() {
let settings = r#"{
"version": 1,
"builtin_agents": [
{
"id": "builtin-5",
"display_name": "Built-in Agent 5",
"kind": "anthropic",
"api_key": "sk-test",
"model": "claude-sonnet-4-5",
"base_url": "https://api.anthropic.com",
"enabled": true
},
{
"id": "builtin-6",
"display_name": "Built-in Agent 6",
"kind": "anthropic",
"api_key": "sk-test",
"model": "claude-sonnet-4-5",
"base_url": "https://api.anthropic.com",
"enabled": true
}
]
}"#;
let payload: SettingsPayload = serde_json::from_str(settings).unwrap();
let mut dst = EditorState::new();
apply_payload(&mut dst, payload);
assert_eq!(dst.editor_ui.agent_settings.builtin_agents.len(), 1);
assert_eq!(
dst.editor_ui.agent_settings.builtin_agents[0].display_name,
"Built-in Agent 5"
);
assert_eq!(dst.editor_ui.agent_settings.next_builtin_agent_id, 6);
}
#[test]
fn acp_agents_round_trip_through_payload() {
let mut src = EditorState::new();
let mut env = std::collections::BTreeMap::new();
env.insert("ACP_TOKEN".into(), "secret".into());
src.editor_ui.agent_settings.add_acp_agent_config(
"Design Agent",
AcpConnectionType::Local,
"/usr/local/bin/design-agent",
vec!["--stdio".into()],
env,
None,
true,
);
src.editor_ui.agent_settings.add_acp_agent_config(
"Remote Agent",
AcpConnectionType::Remote,
"",
Vec::new(),
std::collections::BTreeMap::new(),
Some("ws://localhost:8100".into()),
false,
);
let json = serde_json::to_string(&to_payload(&src)).unwrap();
let payload: SettingsPayload = serde_json::from_str(&json).unwrap();
let mut dst = EditorState::new();
apply_payload(&mut dst, payload);
assert_eq!(dst.editor_ui.agent_settings.acp_agents.len(), 2);
let local = &dst.editor_ui.agent_settings.acp_agents[0];
assert_eq!(local.display_name, "Design Agent");
assert_eq!(local.connection_type, AcpConnectionType::Local);
assert_eq!(local.command, "/usr/local/bin/design-agent");
assert!(!local.connected);
assert_eq!(local.args, vec!["--stdio"]);
assert_eq!(
local.env.get("ACP_TOKEN").map(String::as_str),
Some("secret")
);
let remote = &dst.editor_ui.agent_settings.acp_agents[1];
assert_eq!(remote.connection_type, AcpConnectionType::Remote);
assert_eq!(remote.url.as_deref(), Some("ws://localhost:8100"));
assert!(!remote.enabled);
assert_eq!(dst.editor_ui.agent_settings.next_acp_agent_id, 3);
}
#[test]
fn image_generation_profiles_round_trip_through_payload() {
let mut src = EditorState::new();
let first = src.editor_ui.agent_settings.add_image_gen_profile();
let second = src.editor_ui.agent_settings.add_image_gen_profile();
let second_profile = &mut src.editor_ui.agent_settings.image_gen_profiles[1];
second_profile.name = "Gemini Image".into();
second_profile.provider = ImageGenProvider::Gemini;
second_profile.api_key = "image-key".into();
second_profile.model = "gemini-image".into();
second_profile.base_url = Some("https://images.example/v1".into());
assert!(src
.editor_ui
.agent_settings
.set_active_image_gen_profile(&second));
let json = serde_json::to_string(&to_payload(&src)).unwrap();
let payload: SettingsPayload = serde_json::from_str(&json).unwrap();
let mut dst = EditorState::new();
apply_payload(&mut dst, payload);
assert_eq!(dst.editor_ui.agent_settings.image_gen_profiles.len(), 2);
assert_eq!(
dst.editor_ui
.agent_settings
.active_image_gen_profile_id
.as_deref(),
Some(second.as_str())
);
assert_eq!(dst.editor_ui.agent_settings.image_gen_profiles[0].id, first);
assert_eq!(
dst.editor_ui.agent_settings.image_gen_profiles[1].provider,
ImageGenProvider::Gemini
);
assert_eq!(
dst.editor_ui.agent_settings.image_gen_profiles[1].base_url,
Some("https://images.example/v1".into())
);
}
#[test]
fn openverse_oauth_round_trips_through_payload() {
let mut src = EditorState::new();
src.editor_ui.agent_settings.openverse_client_id = "client-id".into();
src.editor_ui.agent_settings.openverse_client_secret = "client-secret".into();
let json = serde_json::to_string(&to_payload(&src)).unwrap();
let payload: SettingsPayload = serde_json::from_str(&json).unwrap();
let mut dst = EditorState::new();
apply_payload(&mut dst, payload);
assert_eq!(
dst.editor_ui.agent_settings.openverse_client_id,
"client-id"
);
assert_eq!(
dst.editor_ui.agent_settings.openverse_client_secret,
"client-secret"
);
}
#[test]
fn auto_update_preference_round_trips_through_payload() {
let mut src = EditorState::new();
src.editor_ui.agent_settings.auto_update_enabled = false;
let json = serde_json::to_string(&to_payload(&src)).unwrap();
let payload: SettingsPayload = serde_json::from_str(&json).unwrap();
let mut dst = EditorState::new();
apply_payload(&mut dst, payload);
assert!(!dst.editor_ui.agent_settings.auto_update_enabled);
}
#[test]
fn legacy_ark_coding_payload_with_doubao_preset_migrates_to_ark_coding() {
let settings = r#"{
"version": 1,
"builtin_agents": [
{
"id": "builtin-3",
"preset": "doubao",
"display_name": "方舟CP",
"kind": "anthropic",
"api_key": "sk-test",
"model": "ark-code-latest",
"base_url": "https://ark.cn-beijing.volces.com/api/coding",
"enabled": true
}
]
}"#;
let payload: SettingsPayload = serde_json::from_str(settings).unwrap();
let mut dst = EditorState::new();
apply_payload(&mut dst, payload);
assert_eq!(dst.editor_ui.agent_settings.builtin_agents.len(), 1);
assert_eq!(
dst.editor_ui.agent_settings.builtin_agents[0].preset,
BuiltinAgentPresetKey::ArkCoding
);
}