feat(agents): improve configuration workflow and add history recovery

- Update configuration files directly while preserving custom settings
- Add versioned backups, restore previews, and managed-field recovery
- Detect first-time setup, outdated configuration, and invalid files
- Add conflict detection and rollback for multi-file updates
- Simplify configuration actions and clarify OAuth mode
- Add backend and UI regression coverage
This commit is contained in:
lzt404 committed 2026-09-11 16:25:14 +08:00
1 parent ca76f344fb
commit 7acc0e9c74
18 files changed
+2128 -564

No files matched your search

+2
View File
@@ -5,10 +5,12 @@ use super::*;
mod commands;
mod configuration;
mod discovery;
mod history;
mod launch;
mod state;
pub(crate) use commands::*;
pub(crate) use configuration::*;
pub(crate) use discovery::*;
pub(crate) use history::*;
pub(crate) use launch::*;
pub(crate) use state::*;
+19 -48
View File
@@ -55,16 +55,7 @@ pub(crate) fn build_agent_updates_with_oauth(
models,
claude_code_model_mappings,
)
.or_else(|_| {
build_claude_agent_config(
None,
&root_base,
api_key,
model,
models,
claude_code_model_mappings,
)
})?;
?;
Ok(vec![AgentFileUpdate {
path: paths[0].clone(),
after,
@@ -82,12 +73,12 @@ pub(crate) fn build_agent_updates_with_oauth(
AgentFileUpdate {
path: paths[0].clone(),
after: build_claude_desktop_deployment_config(normal_before.as_deref())
.or_else(|_| build_claude_desktop_deployment_config(None))?,
?,
},
AgentFileUpdate {
path: paths[1].clone(),
after: build_claude_desktop_deployment_config(threep_before.as_deref())
.or_else(|_| build_claude_desktop_deployment_config(None))?,
?,
},
AgentFileUpdate {
path: paths[2].clone(),
@@ -99,21 +90,12 @@ pub(crate) fn build_agent_updates_with_oauth(
models,
claude_desktop_model_mappings,
)
.or_else(|_| {
build_claude_desktop_profile(
None,
&root_base,
api_key,
model,
models,
claude_desktop_model_mappings,
)
})?,
?,
},
AgentFileUpdate {
path: paths[3].clone(),
after: build_claude_desktop_meta(meta_before.as_deref())
.or_else(|_| build_claude_desktop_meta(None))?,
?,
},
])
}
@@ -126,15 +108,7 @@ pub(crate) fn build_agent_updates_with_oauth(
model,
oauth_configuration,
)
.or_else(|_| {
build_codex_agent_config_with_oauth(
None,
&openai_base,
api_key,
model,
oauth_configuration,
)
})?;
?;
let mut updates = vec![AgentFileUpdate {
path: paths[0].clone(),
after,
@@ -157,7 +131,7 @@ pub(crate) fn build_agent_updates_with_oauth(
model,
models,
)
.or_else(|_| build_opencode_agent_config(None, &openai_base, api_key, model, models))?;
?;
Ok(vec![AgentFileUpdate {
path: paths[0].clone(),
after,
@@ -172,7 +146,7 @@ pub(crate) fn build_agent_updates_with_oauth(
model,
models,
)
.or_else(|_| build_openclaw_agent_config(None, &openai_base, api_key, model, models))?;
?;
Ok(vec![AgentFileUpdate {
path: paths[0].clone(),
after,
@@ -182,9 +156,7 @@ pub(crate) fn build_agent_updates_with_oauth(
let before = read_optional_text(&paths[0])?;
let after =
build_hermes_agent_config(before.as_deref(), &openai_base, api_key, model, models)
.or_else(|_| {
build_hermes_agent_config(None, &openai_base, api_key, model, models)
})?;
?;
Ok(vec![AgentFileUpdate {
path: paths[0].clone(),
after,
@@ -220,9 +192,7 @@ pub(crate) fn build_agent_updates_with_oauth(
let cli_before = read_optional_text(&paths[1])?;
let app_after =
build_zcode_agent_config(app_before.as_deref(), &root_base, api_key, model, models)
.or_else(|_| {
build_zcode_agent_config(None, &root_base, api_key, model, models)
})?;
?;
let cli_after = build_zcode_cli_agent_config(
cli_before.as_deref(),
&root_base,
@@ -230,7 +200,7 @@ pub(crate) fn build_agent_updates_with_oauth(
model,
models,
)
.or_else(|_| build_zcode_cli_agent_config(None, &root_base, api_key, model, models))?;
?;
Ok(vec![
AgentFileUpdate {
path: paths[0].clone(),
@@ -251,9 +221,7 @@ pub(crate) fn build_agent_updates_with_oauth(
model,
models,
)
.or_else(|_| {
build_kimi_code_agent_config(None, &openai_base, api_key, model, models)
})?;
?;
Ok(vec![AgentFileUpdate {
path: paths[0].clone(),
after,
@@ -268,9 +236,7 @@ pub(crate) fn build_agent_updates_with_oauth(
model,
models,
)
.or_else(|_| {
build_grok_build_agent_config(None, &openai_base, api_key, model, models)
})?;
?;
Ok(vec![AgentFileUpdate {
path: paths[0].clone(),
after,
@@ -2669,7 +2635,12 @@ pub(crate) fn build_codex_auth_update(
read_optional_text(&path)?
.ok_or_else(|| "Codex OAuth 登录凭据在应用配置前已被删除,请重新登录".to_string())?
} else {
build_codex_api_auth(api_key)?
let path_text = read_optional_text(&path)?;
let mut root = parse_agent_json_object(path_text.as_deref(), "Codex auth.json")?;
for key in ["tokens", "last_refresh"] { root.remove(key); }
root.insert("auth_mode".into(), serde_json::json!("apikey"));
root.insert("OPENAI_API_KEY".into(), serde_json::json!(api_key));
render_agent_json(root, "Codex auth.json")?
};
Ok(AgentFileUpdate { path, after })
}
+62 -58
View File
@@ -425,6 +425,7 @@ pub(crate) fn inspect_pi_provider_status(
config_valid,
configured,
configuration_synchronized: configured,
connection_state: agent_connection_state(configured, config_valid, Ok(config_path.is_file() || default_provider_matches)).into(),
current_model: current_model.clone(),
oauth_configuration: false,
modification_enabled: configured,
@@ -455,7 +456,7 @@ pub(crate) fn install_pi_provider_inner(
let settings_path = pi_provider_settings_path(home);
let mut changed_files = Vec::new();
if !pi_provider_package_installed(home)? {
install_pi_package(executable, home, proxy_url)?;
history_package_operation(home, "plugin-install", || install_pi_package(executable, home, proxy_url))?;
changed_files.push(path_to_string(&settings_path));
}
@@ -479,7 +480,8 @@ pub(crate) fn repair_pi_provider_inner(
}
let config_path = pi_provider_config_path(home);
let settings_path = pi_provider_settings_path(home);
let mut changed_files = Vec::new();
let _guard = AGENT_CONFIG_FILE_LOCK.lock().map_err(|_| "配置文件锁已损坏")?;
let before = history_images(&history_paths(PI_AGENT_ID, home)?)?;
if !pi_provider_package_installed(home)? {
return Err("Pi CLIProxyAPI provider is not installed".to_string());
}
@@ -495,11 +497,6 @@ pub(crate) fn repair_pi_provider_inner(
};
let base_url = managed_core_loopback_origin(port);
let rendered = build_pi_provider_config(existing.as_deref(), &base_url, api_key)?;
if existing.as_deref() != Some(rendered.as_str()) {
write_bytes_atomically(&config_path, rendered.as_bytes())?;
changed_files.push(path_to_string(&config_path));
}
let settings = fs::read_to_string(&settings_path).map_err(|error| {
format!(
"读取 Pi settings.json 失败 {}: {error}",
@@ -507,18 +504,10 @@ pub(crate) fn repair_pi_provider_inner(
)
})?;
let rendered_settings = build_pi_provider_settings(&settings, default_model)?;
if settings != rendered_settings {
write_bytes_atomically(&settings_path, rendered_settings.as_bytes())?;
changed_files.push(path_to_string(&settings_path));
}
Ok(action_result(
"applied",
true,
Some(default_model.trim().to_string()),
changed_files,
Vec::new(),
))
history_updates(PI_AGENT_ID, home, &before, &[
AgentFileUpdate { path: config_path, after: rendered },
AgentFileUpdate { path: settings_path, after: rendered_settings },
], "update", Some(default_model.to_string()), None)
}
pub(crate) fn update_pi_provider_inner(
@@ -532,7 +521,7 @@ pub(crate) fn update_pi_provider_inner(
if !pi_provider_package_installed(home)? {
return Err("Pi CLIProxyAPI provider 插件尚未安装".to_string());
}
update_pi_package(executable, home, proxy_url)?;
history_package_operation(home, "plugin-update", || update_pi_package(executable, home, proxy_url))?;
let mut result = repair_pi_provider_inner(home, port, api_key, default_model)?;
result.outcome = "updated".to_string();
Ok(result)
@@ -551,7 +540,7 @@ pub(crate) fn uninstall_pi_provider_inner(
Vec::new(),
));
}
remove_pi_package(executable, home)?;
history_package_operation(home, "plugin-remove", || remove_pi_package(executable, home))?;
Ok(action_result(
"removed",
false,
@@ -776,24 +765,13 @@ pub(crate) fn remove_codex_config_file(path: &Path) -> Result<bool, String> {
}
pub(crate) fn clear_codex_config_files(home: &Path) -> Result<Vec<String>, String> {
let codex_dir = codex_configuration_directory(home);
let config_path = codex_dir.join("config.toml");
let targets = [codex_dir.join("auth.json"), config_path.clone()];
let mut deleted = Vec::new();
for path in targets {
if remove_codex_config_file(&path)? {
deleted.push(path_to_string(&path));
}
let paths = history_paths("codex",home)?;
let before = history_images(&paths)?;
let mut after = before.clone();
for (path, bytes) in &mut after {
if path.file_name().and_then(|v| v.to_str()).is_some_and(|v| v == "auth.json" || v == "config.toml") { *bytes = None; }
}
let state_path = agent_state_path(std::slice::from_ref(&config_path))?;
clear_codex_applied_state(&state_path)?;
// Clean up state files left by older releases. New Codex applications keep
// this short-lived restore metadata in memory instead.
remove_codex_config_file(&state_path)?;
Ok(deleted)
Ok(commit_history("codex", &paths, &before, &after, "clear", None)?.changed_files)
}
pub(crate) fn codex_model_catalog_path(home: &Path) -> PathBuf {
@@ -997,9 +975,9 @@ pub(crate) fn inspect_agent_config(
if let Some(message) = error.as_ref() {
warnings.push(message.clone());
}
let modification = inspect_agent_application(client, home);
let configuration_synchronized = agent_configuration_is_synchronized(client, home, configured);
warnings.extend(modification.warnings.iter().cloned());
// Legacy backup warnings belong in the history dialog, not the current configuration status.
AgentConfigStatus {
id: client.id().to_string(),
@@ -1017,16 +995,17 @@ pub(crate) fn inspect_agent_config(
config_valid,
configured,
configuration_synchronized,
current_model,
connection_state: agent_connection_state(configured, config_valid, agent_has_connection_evidence(client, &paths)).into(),
current_model: current_model.clone(),
oauth_configuration,
modification_enabled: modification.enabled,
modification_state: modification.state,
backup_available: modification.backup_available,
applied_model: modification.applied_model,
modification_enabled: configured,
modification_state: if !config_valid { "invalid" } else if configured { "applied" } else { "unconfigured" }.into(),
backup_available: false,
applied_model: current_model,
claude_code_model_mappings: (client == AgentClient::ClaudeCode)
.then(|| inspect_claude_code_model_mappings(&paths[0]).ok().flatten())
.flatten(),
claude_desktop_model_mappings: modification.claude_desktop_model_mappings,
claude_desktop_model_mappings: (client == AgentClient::ClaudeDesktop).then(|| current_desktop_history_mappings(home)).flatten(),
warnings,
error,
}
@@ -1133,20 +1112,11 @@ pub(crate) fn agent_launch_targets(
}
pub(crate) fn agent_configuration_is_synchronized(
client: AgentClient,
home: &Path,
_client: AgentClient,
_home: &Path,
configured: bool,
) -> bool {
if !configured {
return false;
}
if !matches!(client, AgentClient::KimiCode | AgentClient::GrokBuild) {
return true;
}
load_agent_applied_state(client, home)
.ok()
.flatten()
.is_some_and(|state| state.configuration_revision >= AGENT_CONFIGURATION_REVISION)
configured
}
pub(crate) fn inspect_agent_application(
@@ -1263,6 +1233,40 @@ pub(crate) fn inspect_agent_managed_config(
}
}
// Configuration health and evidence of CPA integration are separate facts.
// A saved history version alone does not mean the current files still use CPA.
pub(crate) fn agent_connection_state(configured: bool, valid: bool, managed: Result<bool, String>) -> &'static str {
if !valid || managed.is_err() { "invalid" }
else if configured { "configured" }
else if managed == Ok(true) { "needs-update" }
else { "not-configured" }
}
pub(crate) fn agent_has_connection_evidence(client: AgentClient, paths: &[PathBuf]) -> Result<bool, String> {
let active_marker = agent_has_managed_marker(client, paths)?;
if active_marker { return Ok(true); }
// Recognize partial configurations too, even if the selected provider/model was removed.
for path in paths {
let Some(bytes) = read_agent_bytes(path)? else { continue; };
let content = std::str::from_utf8(&bytes).map_err(|e| e.to_string())?;
let value: serde_json::Value = match path.extension().and_then(|v| v.to_str()) {
Some("toml") => serde_json::to_value(toml::from_str::<toml::Value>(content).map_err(|e| e.to_string())?).map_err(|e| e.to_string())?,
Some("yaml" | "yml") => serde_yaml::from_str(content).map_err(|e| e.to_string())?,
_ => json5::from_str(content).map_err(|e| e.to_string())?,
};
let provider_present = ["/provider/cpa-gui", "/model_providers/cpa-gui", "/providers/cpa-gui", "/models/providers/cpa-gui"]
.iter().any(|pointer| value.pointer(pointer).is_some());
let selected = ["/model_provider", "/model", "/model/main", "/model/provider", "/default_model", "/models/default", "/agents/defaults/model/primary"]
.iter().filter_map(|pointer| value.pointer(pointer).and_then(serde_json::Value::as_str))
.any(|model| model == MANAGED_AGENT_PROVIDER_ID || model.starts_with(&format!("{MANAGED_AGENT_PROVIDER_ID}/")));
let catalog = client == AgentClient::Codex && value.get("model_catalog_json").and_then(serde_json::Value::as_str) == Some(CODEX_MODEL_CATALOG_FILE);
let hermes_provider = client == AgentClient::Hermes && value.get("custom_providers").and_then(serde_json::Value::as_array)
.is_some_and(|items| items.iter().any(|item| item.get("name").and_then(serde_json::Value::as_str) == Some(MANAGED_AGENT_PROVIDER_ID)));
if provider_present || selected || catalog || hermes_provider { return Ok(true); }
}
Ok(false)
}
pub(crate) fn agent_has_managed_marker(
client: AgentClient,
paths: &[PathBuf],
File diff suppressed because it is too large. Load diff
+407
View File
@@ -0,0 +1,407 @@
use super::*;
struct Home(PathBuf);
impl Home {
fn new() -> Self {
let path = std::env::temp_dir().join(format!(
"cpa-history-{}-{}",
std::process::id(),
SEQUENCE.fetch_add(1, Ordering::Relaxed)
));
fs::create_dir_all(&path).unwrap();
Self(path)
}
}
impl Drop for Home {
fn drop(&mut self) {
let _ = fs::remove_dir_all(&self.0);
}
}
fn save(path: &Path, value: &str) {
fs::create_dir_all(path.parent().unwrap()).unwrap();
fs::write(path, value).unwrap();
}
fn restore_case(client: &str, originals: &[&str], currents: &[&str]) -> Vec<Value> {
let home = Home::new();
let paths = history_paths(client, &home.0).unwrap();
for (p, v) in paths.iter().zip(originals) {
save(p, v);
}
let version = make_version(
client,
&paths,
&history_images(&paths).unwrap(),
"update",
None,
);
for (p, v) in paths.iter().zip(currents) {
save(p, v);
}
let after =
restored_images(client, &paths, &history_images(&paths).unwrap(), &version).unwrap();
after
.iter()
.map(|(p, b)| parse(p, text(b.as_deref()).unwrap()).unwrap())
.collect()
}
#[test]
fn codex_restore_keeps_new_custom_settings_and_does_not_resurrect_deleted_settings() {
let result = restore_case("codex", &[
"model = 'old'\nremoved_custom = true\n[model_providers.cpa-gui]\nbase_url = 'http://old'\ncustom_deleted = true\n",
r#"{"models":[],"deleted_metadata":1}"#,
r#"{"tokens":{"access_token":"old-secret"},"deleted_custom":true}"#,
], &[
"# Keep comment\nmodel = 'new'\nnew_custom = 42\n[model_providers.cpa-gui]\nbase_url = 'http://new'\ncustom_added = true\n",
r#"{"models":[{"slug":"new"}],"new_metadata":2}"#,
r#"{"OPENAI_API_KEY":"new-secret","new_custom":42}"#,
]);
assert_eq!(result[0]["model"], "old");
assert_eq!(result[0]["new_custom"], 42);
assert!(result[0].get("removed_custom").is_none());
assert!(result[0]["model_providers"]["cpa-gui"]
.get("custom_deleted")
.is_none());
assert_eq!(
result[0]["model_providers"]["cpa-gui"]["custom_added"],
true
);
assert_eq!(result[1]["new_metadata"], 2);
assert!(result[1].get("deleted_metadata").is_none());
assert_eq!(result[2]["tokens"]["access_token"], "old-secret");
assert!(result[2].get("OPENAI_API_KEY").is_none());
assert_eq!(result[2]["new_custom"], 42);
assert!(result[2].get("deleted_custom").is_none());
}
#[test]
fn claude_restore_removes_absent_managed_fields_and_preserves_custom_env() {
let result = restore_case(
"claude-code",
&[r#"{"env":{"ANTHROPIC_MODEL":"old","DELETED":"x"}}"#],
&[
r#"{"env":{"ANTHROPIC_MODEL":"new","ANTHROPIC_DEFAULT_SONNET_MODEL":"new","CLAUDE_CODE_SUBAGENT_MODEL":"new","CUSTOM":"keep"}}"#,
],
);
assert_eq!(result[0]["env"]["ANTHROPIC_MODEL"], "old");
assert_eq!(result[0]["env"]["CUSTOM"], "keep");
for key in [
"DELETED",
"ANTHROPIC_DEFAULT_SONNET_MODEL",
"CLAUDE_CODE_SUBAGENT_MODEL",
] {
assert!(result[0]["env"].get(key).is_none());
}
}
#[test]
fn hermes_restore_does_not_resurrect_deleted_unmanaged_provider() {
let result = restore_case("hermes", &["custom_providers:\n - name: personal\n api_key: custom\n - name: cpa-gui\n api_key: old\n custom_deleted: true\n"], &["theme: dark\n"]);
assert_eq!(result[0]["theme"], "dark");
let providers = result[0]["custom_providers"].as_array().unwrap();
assert_eq!(providers.len(), 1);
assert_eq!(providers[0]["name"], "cpa-gui");
assert!(providers[0].get("custom_deleted").is_none());
}
#[test]
fn pi_restore_leaves_packages_and_custom_values_untouched() {
let result = restore_case(
"pi",
&[
r#"{"apiKey":"old"}"#,
r#"{"defaultModel":"old","packages":["old-package"]}"#,
],
&[
r#"{"apiKey":"new","custom":1}"#,
r#"{"defaultModel":"new","packages":["new-package"],"theme":"dark"}"#,
],
);
assert_eq!(result[0]["apiKey"], "old");
assert_eq!(result[0]["custom"], 1);
assert_eq!(result[1]["defaultModel"], "old");
assert_eq!(result[1]["packages"][0], "new-package");
}
#[test]
fn missing_files_restore_and_invalid_current_file_is_rejected() {
let home = Home::new();
let paths = history_paths("codex", &home.0).unwrap();
let absent = history_images(&paths).unwrap();
let missing = make_version("codex", &paths, &absent, "before-update", None);
save(&paths[0], "model = 'new'\n");
let after =
restored_images("codex", &paths, &history_images(&paths).unwrap(), &missing).unwrap();
assert!(after[0].1.is_none());
let saved = make_version(
"codex",
&paths,
&history_images(&paths).unwrap(),
"update",
None,
);
let after = restored_images("codex", &paths, &absent, &saved).unwrap();
assert!(after[0].1.is_some());
save(&paths[0], "{{invalid");
assert!(restored_images("codex", &paths, &history_images(&paths).unwrap(), &saved).is_err());
}
#[test]
fn backup_failure_and_external_conflict_leave_current_files_untouched() {
let home = Home::new();
let paths = history_paths("codex", &home.0).unwrap();
save(&paths[0], "model = 'old'\n");
let before = history_images(&paths).unwrap();
let mut after = before.clone();
after[0].1 = Some(b"model = 'new'\n".to_vec());
save(&paths[0], "model = 'external'\n");
assert!(
commit_history("codex", &paths, &before, &after, "update", None)
.err()
.unwrap()
.contains("其他程序")
);
assert_eq!(
fs::read_to_string(&paths[0]).unwrap(),
"model = 'external'\n"
);
fs::write(&paths[0], before[0].1.as_ref().unwrap()).unwrap();
let dir = history_directory("codex", &paths);
fs::create_dir_all(dir.parent().unwrap()).unwrap();
fs::write(&dir, "blocked").unwrap();
assert!(commit_history("codex", &paths, &before, &after, "update", None).is_err());
assert_eq!(history_images(&paths).unwrap(), before);
}
#[test]
fn partial_multi_file_write_rolls_back_and_keeps_recovery_snapshot() {
let home = Home::new();
let paths = history_paths("codex", &home.0).unwrap();
save(&paths[0], "model = 'old'\n");
let before = history_images(&paths).unwrap();
let mut after = before.clone();
after[0].1 = Some(b"model = 'new'\n".to_vec());
after[1].1 = Some(b"{\"models\":[]}".to_vec());
let mut first = true;
let result = commit_history_with_writer(
"codex",
&paths,
&before,
&after,
"update",
None,
None,
&mut |client, images| {
if first {
first = false;
history_write_images(client, &vec![images[0].clone()])?;
return Err("injected second-file failure".into());
}
history_write_images(client, images)
},
);
assert!(result.is_err());
assert_eq!(history_images(&paths).unwrap(), before);
assert_eq!(versions("codex", &paths).unwrap().0.len(), 1);
}
#[test]
fn preview_masks_auth_and_revision_tracks_external_changes_and_versions_are_validated() {
let home = Home::new();
let paths = history_paths("codex", &home.0).unwrap();
save(&paths[2], r#"{"OPENAI_API_KEY":"secret-old"}"#);
let version = make_version(
"codex",
&paths,
&history_images(&paths).unwrap(),
"update",
None,
);
write_version(&paths, &version).unwrap();
save(&paths[2], r#"{"OPENAI_API_KEY":"secret-new"}"#);
let first = preview("codex", &paths, &version.id).unwrap().0;
assert_eq!(first.differences[0].before, "••••••");
assert_eq!(first.differences[0].after, "••••••");
save(&paths[0], "custom = true\n");
assert_ne!(
first.revision,
preview("codex", &paths, &version.id).unwrap().0.revision
);
assert!(read_version("codex", &paths, "../escape").is_err());
let mut tampered = version.clone();
tampered.files[0].path = home.0.join("outside");
fs::write(
version_path("codex", &paths, &version.id).unwrap(),
serde_json::to_vec(&tampered).unwrap(),
)
.unwrap();
assert!(read_version("codex", &paths, &version.id).is_err());
}
#[test]
fn verified_legacy_import_is_deduplicated_and_keeps_original_backup() {
let home = Home::new();
let paths = history_paths("opencode", &home.0).unwrap();
save(&paths[0], r#"{"model":"current"}"#);
let backup = agent_backup_path(&paths[0]).unwrap();
save(&backup, r#"{"model":"old"}"#);
let record = AgentModificationRecord {
version: AGENT_MODIFICATION_STATE_VERSION,
client: "opencode".into(),
phase: AGENT_PHASE_ACTIVE.into(),
model: "current".into(),
files: vec![AgentModificationFile {
path: paths[0].clone(),
backup_path: backup.clone(),
existed_before: true,
original_sha256: Some(sha256_bytes(&fs::read(&backup).unwrap())),
managed_sha256: String::new(),
}],
};
save(
&agent_state_path(&paths).unwrap(),
&serde_json::to_string(&record).unwrap(),
);
assert!(import_legacy_history("opencode", &home.0, &paths)
.unwrap()
.is_empty());
assert!(import_legacy_history("opencode", &home.0, &paths)
.unwrap()
.is_empty());
assert_eq!(versions("opencode", &paths).unwrap().0.len(), 1);
assert!(backup.exists());
let mut bad = record;
bad.files[0].original_sha256 = Some("wrong".into());
save(
&agent_state_path(&paths).unwrap(),
&serde_json::to_string(&bad).unwrap(),
);
assert!(!import_legacy_history("opencode", &home.0, &paths)
.unwrap()
.is_empty());
assert!(backup.exists());
}
#[test]
fn semantic_noop_preserves_exact_comments_and_does_not_save_history() {
let home = Home::new();
let paths = history_paths("opencode", &home.0).unwrap();
save(&paths[0], "// Keep this comment\n{model: 'old'}\n");
let before = history_images(&paths).unwrap();
let result = history_updates(
"opencode",
&home.0,
&before,
&[AgentFileUpdate {
path: paths[0].clone(),
after: r#"{"model":"old"}"#.into(),
}],
"update",
None,
None,
)
.unwrap();
assert_eq!(result.outcome, "unchanged");
assert_eq!(history_images(&paths).unwrap(), before);
assert_eq!(versions("opencode", &paths).unwrap().0.len(), 0);
let target = make_version("opencode", &paths, &before, "update", None);
save(
&paths[0],
"// Current comment\n{model: 'new',custom: true}\n",
);
let after = restored_images(
"opencode",
&paths,
&history_images(&paths).unwrap(),
&target,
)
.unwrap();
assert!(text(after[0].1.as_deref())
.unwrap()
.unwrap()
.contains("// Current comment"));
}
#[test]
fn catalog_update_preserves_custom_metadata_and_rejects_invalid_existing_catalog() {
let home = Home::new();
let paths = history_paths("codex", &home.0).unwrap();
save(&paths[1], r#"{"models":[],"custom":42}"#);
let updates = vec![AgentFileUpdate {
path: paths[1].clone(),
after: r#"{"models":[{"slug":"new"}]}"#.into(),
}];
history_updates(
"codex",
&home.0,
&history_images(&paths).unwrap(),
&updates,
"sync",
None,
None,
)
.unwrap();
assert_eq!(
parse(&paths[1], Some(&fs::read_to_string(&paths[1]).unwrap())).unwrap()["custom"],
42
);
save(&paths[1], "invalid");
assert!(history_updates(
"codex",
&home.0,
&history_images(&paths).unwrap(),
&updates,
"update",
None,
None
)
.is_err());
assert_eq!(fs::read_to_string(&paths[1]).unwrap(), "invalid");
}
#[test]
fn pi_package_changes_are_backed_up_and_failures_roll_back_only_config_files() {
let home = Home::new();
let paths = history_paths("pi", &home.0).unwrap();
save(&paths[1], r#"{"packages":[]}"#);
history_package_operation(&home.0, "plugin-install", || {
save(&paths[1], r#"{"packages":["installed"]}"#);
Ok(())
})
.unwrap();
assert_eq!(versions("pi", &paths).unwrap().0.len(), 2);
let before = history_images(&paths).unwrap();
let package = home.0.join("package-installed");
assert!(history_package_operation(&home.0, "plugin-update", || {
save(&paths[1], "{}");
save(&package, "package is outside config history");
Err("package failure".into())
})
.is_err());
assert_eq!(history_images(&paths).unwrap(), before);
assert!(package.exists());
}
#[test]
fn desktop_profile_index_restores_only_managed_entry_fields() {
let home = Home::new();
let paths = history_paths("claude-desktop", &home.0).unwrap();
let old = serde_json::json!({"appliedId":CLAUDE_DESKTOP_PROFILE_ID,"entries":[{"id":CLAUDE_DESKTOP_PROFILE_ID,"name":"old","deleted_custom":true},{"id":"deleted-personal"}]});
save(&paths[3], &old.to_string());
let version = make_version(
"claude-desktop",
&paths,
&history_images(&paths).unwrap(),
"update",
None,
);
let current = serde_json::json!({"entries":[{"id":CLAUDE_DESKTOP_PROFILE_ID,"name":"new","new_custom":42},{"id":"new-personal"}]});
save(&paths[3], &current.to_string());
let after = restored_images(
"claude-desktop",
&paths,
&history_images(&paths).unwrap(),
&version,
)
.unwrap();
let value = parse(&paths[3], text(after[3].1.as_deref()).unwrap()).unwrap();
let entries = value["entries"].as_array().unwrap();
assert_eq!(entries.len(), 2);
assert_eq!(entries[0]["id"], "new-personal");
assert_eq!(entries[1]["name"], "old");
assert_eq!(entries[1]["new_custom"], 42);
assert!(entries[1].get("deleted_custom").is_none());
}
#[test]
fn directory_at_config_path_is_an_error() {
let home = Home::new();
let paths = history_paths("codex", &home.0).unwrap();
fs::create_dir_all(&paths[0]).unwrap();
assert!(history_images(&paths).is_err());
}
+17 -158
View File
@@ -305,7 +305,7 @@ pub(crate) fn load_agent_applied_state(
}
pub(crate) fn read_agent_bytes(path: &Path) -> Result<Option<Vec<u8>>, String> {
if !path.is_file() {
if !path.exists() {
return Ok(None);
}
fs::read(path)
@@ -1402,119 +1402,11 @@ pub(crate) fn commit_agent_configuration(
model: &str,
updates: &[AgentFileUpdate],
outcome: &str,
claude_desktop_model_mappings: Option<&ClaudeDesktopModelMappings>,
_claude_desktop_model_mappings: Option<&ClaudeDesktopModelMappings>,
) -> Result<AgentConfigActionResult, String> {
let base_paths = agent_config_paths(client, home);
let state_path = agent_state_path(&base_paths)?;
let existing_state = load_agent_applied_state(client, home)?;
let previous_state = read_agent_bytes(&state_path)?;
let mut legacy_backups = previous_state
.as_deref()
.and_then(|content| std::str::from_utf8(content).ok())
.map(legacy_agent_backup_paths)
.unwrap_or_default();
legacy_backups.extend(
expected_agent_record_paths(client, &base_paths)
.iter()
.filter_map(|path| agent_backup_path(path).ok()),
);
legacy_backups.sort();
legacy_backups.dedup();
let mut snapshots = updates
.iter()
.map(|update| Ok((update.path.clone(), read_agent_bytes(&update.path)?)))
.collect::<Result<Vec<_>, String>>()?;
snapshots.push((state_path.clone(), previous_state));
let mut created_backup_paths = Vec::new();
let backup_files =
if let Some(state) = existing_state.filter(|state| !state.backup_files.is_empty()) {
let mut backup_files = state.backup_files;
let missing_updates = updates
.iter()
.filter(|update| !backup_files.iter().any(|file| file.path == update.path))
.map(|update| AgentFileUpdate {
path: update.path.clone(),
after: update.after.clone(),
})
.collect::<Vec<_>>();
let added_backups = prepare_agent_session_backups(client, &missing_updates)?;
created_backup_paths.extend(
added_backups
.iter()
.filter(|file| file.existed_before)
.map(|file| file.backup_path.clone()),
);
backup_files.extend(added_backups);
backup_files
} else {
let backup_files = prepare_agent_session_backups(client, updates)?;
created_backup_paths.extend(
backup_files
.iter()
.filter(|file| file.existed_before)
.map(|file| file.backup_path.clone()),
);
backup_files
};
let transaction = (|| -> Result<Vec<String>, String> {
let mut changed = Vec::new();
for update in updates {
let next = update.after.as_bytes();
if read_agent_bytes(&update.path)?.as_deref() == Some(next) {
continue;
}
write_agent_configuration_file(client, &update.path, next)?;
changed.push(path_to_string(&update.path));
}
let state = AgentAppliedState {
version: AGENT_APPLIED_STATE_VERSION,
client: client.id().to_string(),
model: model.to_string(),
configuration_revision: AGENT_CONFIGURATION_REVISION,
claude_desktop_model_mappings: (client == AgentClient::ClaudeDesktop)
.then(|| claude_desktop_model_mappings.cloned())
.flatten(),
backup_files: backup_files.clone(),
updated_at_unix: SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_secs(),
};
write_agent_applied_state(&state_path, &state)?;
Ok(changed)
})();
match transaction {
Ok(changed) => {
for backup_path in legacy_backups {
if backup_path.is_file() {
let _ = fs::remove_file(backup_path);
}
}
Ok(action_result(
outcome,
true,
Some(model.to_string()),
changed,
Vec::new(),
))
}
Err(error) => {
let rollback = restore_agent_snapshots_direct(&snapshots);
for backup_path in created_backup_paths {
if backup_path.is_file() {
let _ = fs::remove_file(backup_path);
}
}
match rollback {
Ok(()) => Err(error),
Err(rollback_error) => Err(format!("{error};回滚配置失败: {rollback_error}")),
}
}
}
let paths = history_paths(client.id(), home)?;
let before = history_images(&paths)?;
history_updates(client.id(), home, &before, updates, outcome, Some(model.to_string()), _claude_desktop_model_mappings)
}
pub(crate) fn sync_codex_model_catalog_if_configured(
@@ -1529,6 +1421,7 @@ pub(crate) fn sync_codex_model_catalog_if_configured(
let _guard = AGENT_CONFIG_FILE_LOCK
.lock()
.map_err(|_| "智能体配置文件锁已损坏".to_string())?;
let before = history_images(&history_paths(client.id(), home)?)?;
let (configured, current_model, _) =
inspect_agent_managed_config(client, &paths, port, api_key)?;
if !configured {
@@ -1585,25 +1478,7 @@ pub(crate) fn sync_codex_model_catalog_if_configured(
return Ok(false);
}
if load_agent_applied_state(client, home)?.is_none() {
for path in [paths[0].clone(), codex_model_catalog_path(home)] {
if !updates.iter().any(|update| update.path == path) {
let after = fs::read_to_string(&path)
.map_err(|error| format!("读取 Codex 配置以创建备份失败: {error}"))?;
updates.push(AgentFileUpdate { path, after });
}
}
}
updates.sort_by_key(|update| update.path != paths[0]);
let result = commit_agent_configuration(
client,
home,
next_model.unwrap_or_default(),
&updates,
"updated",
None,
)?;
let result = history_updates(client.id(), home, &before, &updates, "sync", next_model.map(str::to_string), None)?;
Ok(!result.changed_files.is_empty())
}
@@ -1641,16 +1516,10 @@ pub(crate) fn apply_agent_configuration_with_oauth(
model: &str,
options: AgentConfigurationOptions<'_>,
) -> Result<AgentConfigActionResult, String> {
let claude_desktop_model_mappings = options.claude_desktop_model_mappings;
let before = history_images(&history_paths(client.id(), home)?)?;
let mappings = options.claude_desktop_model_mappings;
let updates = build_agent_updates_with_oauth(client, home, port, api_key, model, options)?;
commit_agent_configuration(
client,
home,
model,
&updates,
"applied",
claude_desktop_model_mappings,
)
history_updates(client.id(), home, &before, &updates, "update", Some(model.to_string()), mappings)
}
#[cfg(test)]
@@ -1709,6 +1578,7 @@ pub(crate) fn reset_agent_configuration_to_default_with_oauth(
claude_code_model_mappings,
claude_desktop_model_mappings,
} = request;
let before = history_images(&history_paths(client.id(), home)?)?;
if client == AgentClient::DeepSeekHarness {
let updates = build_agent_updates_with_oauth(
client,
@@ -1724,14 +1594,7 @@ pub(crate) fn reset_agent_configuration_to_default_with_oauth(
claude_desktop_model_mappings,
},
)?;
return commit_agent_configuration(
client,
home,
model,
&updates,
"default",
claude_desktop_model_mappings,
);
return history_updates(client.id(), home, &before, &updates, "default", Some(model.to_string()), claude_desktop_model_mappings);
}
let paths = agent_config_paths(client, home);
let contents = fresh_agent_contents_with_oauth(
@@ -1762,16 +1625,11 @@ pub(crate) fn reset_agent_configuration_to_default_with_oauth(
path: codex_model_catalog_path(home),
after: catalog.to_string(),
});
updates.push(build_codex_auth_update(home, api_key, oauth_configuration)?);
updates.push(if oauth_configuration { build_codex_auth_update(home, api_key, true)? } else {
AgentFileUpdate { path: codex_configuration_directory(home).join("auth.json"), after: build_codex_api_auth(api_key)? }
});
}
commit_agent_configuration(
client,
home,
model,
&updates,
"default",
claude_desktop_model_mappings,
)
history_updates(client.id(), home, &before, &updates, "default", Some(model.to_string()), claude_desktop_model_mappings)
}
#[cfg(test)]
@@ -1890,6 +1748,7 @@ pub(crate) fn action_result(
model,
changed_files,
conflict_files,
history_version: None,
}
}
+5
View File
@@ -1026,6 +1026,7 @@ struct AgentConfigStatus {
config_valid: bool,
configured: bool,
configuration_synchronized: bool,
connection_state: String,
current_model: Option<String>,
oauth_configuration: bool,
modification_enabled: bool,
@@ -1077,6 +1078,7 @@ struct AgentConfigActionResult {
model: Option<String>,
changed_files: Vec<String>,
conflict_files: Vec<String>,
history_version: Option<String>,
}
#[derive(Clone, Debug, Serialize)]
@@ -2483,6 +2485,9 @@ fn main() {
get_software_settings,
save_software_settings,
get_agent_config_statuses,
list_agent_config_history,
preview_agent_config_history,
restore_agent_config_history,
refresh_agent_config_statuses,
get_agent_models,
check_pi_provider_update,
+102 -139
View File
@@ -167,45 +167,26 @@ fn codex_oauth_requires_auth_json_with_tokens() {
}
#[test]
fn agent_configuration_is_restored_from_the_dated_session_backup_when_closed() {
let home = agent_test_home("session-backup");
let path = home.join(".config/opencode/opencode.json");
let original = b"{\"provider\":\"original\"}";
fs::create_dir_all(path.parent().unwrap()).unwrap();
fs::write(&path, original).unwrap();
commit_agent_configuration(
AgentClient::OpenCode,
&home,
"gpt-test",
&[AgentFileUpdate {
path: path.clone(),
after: "{\"provider\":\"managed\"}".to_string(),
}],
"applied",
None,
)
.unwrap();
let backup = dated_agent_backup_path(&path).unwrap();
assert!(backup.is_file());
assert_eq!(fs::read(&backup).unwrap(), original);
assert!(fs::read_to_string(&path).unwrap().contains("managed"));
let inspection = inspect_agent_application(AgentClient::OpenCode, &home);
assert_eq!(inspection.state, "applied");
assert!(inspection.backup_available);
restore_agent_session_configuration(AgentClient::OpenCode, &home).unwrap();
assert_eq!(fs::read(&path).unwrap(), original);
assert!(!backup.exists());
assert!(!agent_state_path(std::slice::from_ref(&path))
.unwrap()
.exists());
fn successive_updates_and_noop_have_independent_history() {
let home = agent_test_home("history-first-update");
let models = test_agent_models(&["gpt-one", "gpt-two"]);
for model in ["gpt-one", "gpt-two"] {
let result = apply_agent_configuration(AgentClient::OpenCode, &home, 8317, DEFAULT_API_KEY, model, &models, None).unwrap();
assert_eq!(result.outcome, "updated");
assert!(result.history_version.is_some());
}
assert_eq!(test_history_count(AgentClient::OpenCode, &home), 4);
let result = apply_agent_configuration(AgentClient::OpenCode, &home, 8317, DEFAULT_API_KEY, "gpt-two", &models, None).unwrap();
assert_eq!(result.outcome, "unchanged");
assert_eq!(test_history_count(AgentClient::OpenCode, &home), 4);
test_restore_history(AgentClient::OpenCode, &home, "update");
let path = agent_config_paths(AgentClient::OpenCode, &home).remove(0);
assert!(fs::read_to_string(path).unwrap().contains("cpa-gui/gpt-one"));
fs::remove_dir_all(home).unwrap();
}
#[test]
fn deepseek_harness_session_restore_keeps_concurrent_unmanaged_yaml_changes() {
fn deepseek_harness_history_restore_keeps_concurrent_unmanaged_yaml_changes() {
let home = agent_test_home("deepseek-harness-session-restore");
let paths = agent_config_paths(AgentClient::DeepSeekHarness, &home);
fs::create_dir_all(paths[0].parent().unwrap()).unwrap();
@@ -265,7 +246,7 @@ agent-default-model:
.unwrap();
fs::write(&paths[1], credentials).unwrap();
restore_agent_session_configuration(AgentClient::DeepSeekHarness, &home).unwrap();
test_restore_history(AgentClient::DeepSeekHarness, &home, "before-update");
let settings: serde_norway::Value =
serde_norway::from_str(&fs::read_to_string(&paths[0]).unwrap()).unwrap();
let credentials: serde_norway::Value =
@@ -297,7 +278,7 @@ agent-default-model:
}
#[test]
fn opencode_runtime_edits_survive_close_and_next_apply_owns_conflicts() {
fn opencode_runtime_edits_survive_history_restore_and_next_apply_owns_conflicts() {
let home = agent_test_home("opencode-runtime-edit-merge");
let path = home.join(".config/opencode/opencode.json");
fs::create_dir_all(path.parent().unwrap()).unwrap();
@@ -331,7 +312,7 @@ fn opencode_runtime_edits_survive_close_and_next_apply_owns_conflicts() {
runtime["model"] = serde_json::json!("cpa-gui/agent-overwrite");
fs::write(&path, serde_json::to_string_pretty(&runtime).unwrap()).unwrap();
restore_agent_session_configuration(AgentClient::OpenCode, &home).unwrap();
test_restore_history(AgentClient::OpenCode, &home, "before-update");
let closed: serde_json::Value =
serde_json::from_str(&fs::read_to_string(&path).unwrap()).unwrap();
assert_eq!(closed["keep"], "root");
@@ -376,12 +357,12 @@ fn opencode_runtime_edits_survive_close_and_next_apply_owns_conflicts() {
assert_eq!(reapplied["agentAdded"]["enabled"], true);
assert_eq!(reapplied["provider"]["other"]["runtimeAdded"], 42);
restore_agent_session_configuration(AgentClient::OpenCode, &home).unwrap();
test_restore_history(AgentClient::OpenCode, &home, "before-update");
fs::remove_dir_all(home).unwrap();
}
#[test]
fn zcode_runtime_edits_survive_close_and_original_provider_is_restored() {
fn zcode_runtime_edits_survive_history_restore_and_original_provider_is_restored() {
let home = agent_test_home("zcode-runtime-edit-merge");
let path = home.join(".zcode/v2/config.json");
fs::create_dir_all(path.parent().unwrap()).unwrap();
@@ -412,7 +393,7 @@ fn zcode_runtime_edits_survive_close_and_original_provider_is_restored() {
serde_json::json!("https://agent-overwrite.invalid");
fs::write(&path, serde_json::to_string_pretty(&runtime).unwrap()).unwrap();
restore_agent_session_configuration(AgentClient::ZCode, &home).unwrap();
test_restore_history(AgentClient::ZCode, &home, "before-update");
let closed: serde_json::Value =
serde_json::from_str(&fs::read_to_string(&path).unwrap()).unwrap();
assert_eq!(closed["keep"], "root");
@@ -438,7 +419,7 @@ fn zcode_runtime_edits_survive_close_and_original_provider_is_restored() {
}
#[test]
fn zcode_legacy_single_file_state_upgrades_and_restores_cli_config() {
fn zcode_incomplete_legacy_backup_is_preserved_and_warned() {
let home = agent_test_home("zcode-legacy-state-upgrade");
let paths = agent_config_paths(AgentClient::ZCode, &home);
fs::create_dir_all(paths[0].parent().unwrap()).unwrap();
@@ -488,23 +469,19 @@ fn zcode_legacy_single_file_state_upgrades_and_restores_cli_config() {
None,
)
.unwrap();
let upgraded = load_agent_applied_state(AgentClient::ZCode, &home)
.unwrap()
.unwrap();
assert_eq!(upgraded.backup_files.len(), 2);
let cli_value: serde_json::Value =
serde_json::from_str(&fs::read_to_string(&paths[1]).unwrap()).unwrap();
assert_eq!(cli_value["model"]["main"], "cpa-gui/gpt-two");
restore_agent_session_configuration(AgentClient::ZCode, &home).unwrap();
assert_eq!(fs::read(&paths[0]).unwrap(), original_app);
assert_eq!(fs::read(&paths[1]).unwrap(), original_cli);
assert!(!state_path.exists());
assert!(!import_legacy_history("zcode", &home, &history_paths("zcode", &home).unwrap()).unwrap().is_empty());
let unchanged = load_agent_applied_state(AgentClient::ZCode, &home).unwrap().unwrap();
assert_eq!(unchanged.backup_files.len(), 1);
assert!(state_path.exists());
assert_eq!(fs::read(&unchanged.backup_files[0].backup_path).unwrap(), original_app);
test_restore_history(AgentClient::ZCode, &home, "before-update");
let cli: serde_json::Value = serde_json::from_slice(&fs::read(&paths[1]).unwrap()).unwrap();
assert_eq!(cli["model"]["main"], "other/original");
fs::remove_dir_all(home).unwrap();
}
#[test]
fn kimi_and_grok_legacy_states_require_context_catalog_resynchronization() {
fn kimi_and_grok_status_does_not_depend_on_legacy_state() {
for client in [AgentClient::KimiCode, AgentClient::GrokBuild] {
let home = agent_test_home(&format!("{}-context-revision", client.id()));
let paths = agent_config_paths(client, &home);
@@ -521,7 +498,7 @@ fn kimi_and_grok_legacy_states_require_context_catalog_resynchronization() {
};
write_agent_applied_state(&state_path, &state).unwrap();
assert!(!agent_configuration_is_synchronized(client, &home, true));
assert!(agent_configuration_is_synchronized(client, &home, true));
state.configuration_revision = AGENT_CONFIGURATION_REVISION;
write_agent_applied_state(&state_path, &state).unwrap();
@@ -1269,15 +1246,12 @@ fn codex_session_restore_does_not_create_a_state_json_file() {
.unwrap();
let backup = dated_agent_backup_path(&config_path).unwrap();
assert!(backup.is_file());
assert!(!backup.exists());
assert!(!state_path.exists());
assert_eq!(
inspect_agent_application(AgentClient::Codex, &home).state,
"applied"
);
assert_eq!(test_history_count(AgentClient::Codex, &home), 2);
restore_agent_session_configuration(AgentClient::Codex, &home).unwrap();
assert_eq!(fs::read(&config_path).unwrap(), original);
test_restore_history(AgentClient::Codex, &home, "before-applied");
assert!(!fs::read_to_string(&config_path).unwrap().contains("approval_policy"));
assert!(!catalog_path.exists());
assert!(!backup.exists());
assert!(!state_path.exists());
@@ -1314,12 +1288,9 @@ fn codex_applied_configuration_remains_detectable_after_process_restart() {
clear_codex_applied_state(&state_path).unwrap();
let inspection = inspect_agent_application(AgentClient::Codex, &home);
assert_eq!(inspection.state, "applied");
assert_eq!(inspection.applied_model.as_deref(), Some("gpt-test"));
assert!(dated_agent_backup_path(&config_path).unwrap().is_file());
restore_agent_session_configuration(AgentClient::Codex, &home).unwrap();
assert_eq!(test_history_count(AgentClient::Codex, &home), 2);
assert!(fs::read_to_string(&config_path).unwrap().contains("gpt-test"));
test_restore_history(AgentClient::Codex, &home, "before-applied");
fs::remove_dir_all(home).unwrap();
}
@@ -1354,7 +1325,7 @@ fn codex_catalog_sync_removes_models_missing_from_runtime() {
let written = fs::read_to_string(&catalog_path).unwrap();
assert!(written.contains("gpt-active"));
assert!(!written.contains("gpt-stale"));
restore_agent_session_configuration(AgentClient::Codex, &home).unwrap();
test_restore_history(AgentClient::Codex, &home, "before-update");
assert!(!catalog_path.exists());
fs::remove_dir_all(home).unwrap();
}
@@ -1400,13 +1371,7 @@ fn codex_catalog_sync_replaces_missing_default_and_preserves_user_configuration(
assert_eq!(document["model"].as_str(), Some("gpt-active"));
assert_eq!(document["approval_policy"].as_str(), Some("on-request"));
assert!(updated.contains("# Keep my settings"));
assert_eq!(
load_agent_applied_state(AgentClient::Codex, &home)
.unwrap()
.unwrap()
.model,
"gpt-active"
);
assert_eq!(test_history_count(AgentClient::Codex, &home), 4);
assert!(!sync_codex_model_catalog_if_configured(
&home,
8317,
@@ -1415,7 +1380,7 @@ fn codex_catalog_sync_replaces_missing_default_and_preserves_user_configuration(
&current_catalog,
)
.unwrap());
restore_agent_session_configuration(AgentClient::Codex, &home).unwrap();
test_restore_history(AgentClient::Codex, &home, "before-update");
assert_eq!(fs::read_to_string(&config_path).unwrap(), original_config);
fs::remove_dir_all(home).unwrap();
}
@@ -1479,7 +1444,7 @@ fn codex_catalog_sync_clears_empty_list_and_recovers_when_models_return() {
assert_eq!(document["model"].as_str(), Some("gpt-returned"));
assert_eq!(fs::read_to_string(&catalog_path).unwrap(), returned_catalog);
assert_eq!(fs::read(&auth_path).unwrap(), original_auth);
restore_agent_session_configuration(AgentClient::Codex, &home).unwrap();
test_restore_history(AgentClient::Codex, &home, "before-update");
assert!(!config_path.exists());
assert!(!catalog_path.exists());
fs::remove_dir_all(home).unwrap();
@@ -1516,14 +1481,9 @@ fn codex_catalog_sync_after_restart_backs_up_managed_files_without_memory_state(
)
.unwrap());
assert_eq!(fs::read_to_string(&catalog_path).unwrap(), current_catalog);
assert_eq!(
fs::read_to_string(dated_agent_backup_path(&catalog_path).unwrap()).unwrap(),
original_catalog
);
assert!(load_agent_applied_state(AgentClient::Codex, &home)
.unwrap()
.is_some());
restore_agent_session_configuration(AgentClient::Codex, &home).unwrap();
assert_eq!(test_history_count(AgentClient::Codex, &home), 4);
test_restore_history(AgentClient::Codex, &home, "before-sync");
assert_eq!(fs::read_to_string(&catalog_path).unwrap(), original_catalog);
fs::remove_dir_all(home).unwrap();
}
@@ -1555,7 +1515,7 @@ fn codex_catalog_only_sync_after_restart_preserves_complete_backups() {
)
.unwrap());
assert_eq!(fs::read(&config_path).unwrap(), original_config);
restore_agent_session_configuration(AgentClient::Codex, &home).unwrap();
test_restore_history(AgentClient::Codex, &home, "before-sync");
assert_eq!(fs::read(&config_path).unwrap(), original_config);
assert_eq!(fs::read_to_string(&catalog_path).unwrap(), original_catalog);
fs::remove_dir_all(home).unwrap();
@@ -1628,7 +1588,7 @@ fn codex_catalog_sync_rejects_invalid_snapshots_without_changing_files() {
serde_json::to_value(load_agent_applied_state(AgentClient::Codex, &home).unwrap()).unwrap(),
state_before
);
restore_agent_session_configuration(AgentClient::Codex, &home).unwrap();
test_restore_history(AgentClient::Codex, &home, "before-update");
fs::remove_dir_all(home).unwrap();
}
@@ -1678,7 +1638,7 @@ fn direct_agent_apply_preserves_unrelated_fields_and_default_reset_removes_them(
Some(&codex_models),
)
.unwrap();
assert_eq!(result.outcome, "applied");
assert_eq!(result.outcome, "updated");
let applied = fs::read_to_string(&path).unwrap();
assert!(applied.contains("# user comment"));
assert!(applied.contains("user_setting = \"keep\""));
@@ -1694,25 +1654,16 @@ fn direct_agent_apply_preserves_unrelated_fields_and_default_reset_removes_them(
assert!(!agent_backup_path(&codex_model_catalog_path(&home))
.unwrap()
.exists());
assert_eq!(
inspect_agent_application(AgentClient::Codex, &home).state,
"applied"
);
assert!(test_history_count(AgentClient::Codex, &home) >= 2);
let mut document = applied.parse::<toml_edit::Document>().unwrap();
document["user_setting"] = toml_edit::value("changed-externally");
fs::write(&path, document.to_string()).unwrap();
assert_eq!(
inspect_agent_application(AgentClient::Codex, &home).state,
"applied"
);
assert!(test_history_count(AgentClient::Codex, &home) >= 2);
document["model"] = toml_edit::value("external-model");
fs::write(&path, document.to_string()).unwrap();
assert_eq!(
inspect_agent_application(AgentClient::Codex, &home).state,
"applied"
);
assert!(test_history_count(AgentClient::Codex, &home) >= 2);
apply_agent_configuration(
AgentClient::Codex,
@@ -1745,7 +1696,7 @@ fn direct_agent_apply_preserves_unrelated_fields_and_default_reset_removes_them(
}
#[test]
fn clear_codex_config_removes_only_requested_config_files_and_application_state() {
fn clear_codex_config_removes_only_requested_config_files_and_preserves_legacy_state() {
let home = agent_test_home("clear-codex-config");
let codex_dir = home.join(".codex");
fs::create_dir_all(&codex_dir).unwrap();
@@ -1763,7 +1714,8 @@ fn clear_codex_config_removes_only_requested_config_files_and_application_state(
assert_eq!(deleted.len(), 2);
assert!(!auth_path.exists());
assert!(!config_path.exists());
assert!(!state_path.exists());
assert!(state_path.exists());
assert_eq!(test_history_count(AgentClient::Codex, &home), 2);
assert_eq!(fs::read_to_string(&preserved_path).unwrap(), "keep");
assert!(clear_codex_config_files(&home).unwrap().is_empty());
@@ -1788,32 +1740,19 @@ fn clear_codex_config_removes_only_requested_config_files_and_application_state(
}
#[test]
fn direct_agent_apply_rebuilds_an_unparseable_file_without_backup() {
fn ordinary_update_rejects_invalid_file_and_explicit_default_backs_it_up() {
let home = agent_test_home("invalid-apply");
let path = home.join(".config/opencode/opencode.json");
fs::create_dir_all(path.parent().unwrap()).unwrap();
fs::write(&path, "{ invalid json").unwrap();
let models = test_agent_models(&["model-a"]);
apply_agent_configuration(
AgentClient::OpenCode,
&home,
8317,
DEFAULT_API_KEY,
"model-a",
&models,
None,
)
.unwrap();
let root =
serde_json::from_str::<serde_json::Value>(&fs::read_to_string(&path).unwrap()).unwrap();
assert_eq!(root["model"], "cpa-gui/model-a");
assert!(!agent_backup_path(&path).unwrap().exists());
assert_eq!(
inspect_agent_application(AgentClient::OpenCode, &home).state,
"applied"
);
assert!(apply_agent_configuration(AgentClient::OpenCode, &home, 8317, DEFAULT_API_KEY, "model-a", &models, None).is_err());
assert_eq!(fs::read_to_string(&path).unwrap(), "{ invalid json");
assert_eq!(test_history_count(AgentClient::OpenCode, &home), 0);
reset_agent_configuration_to_default(AgentClient::OpenCode, &home, 8317, DEFAULT_API_KEY, "model-a", None).unwrap();
assert_eq!(test_history_count(AgentClient::OpenCode, &home), 2);
assert!(serde_json::from_slice::<serde_json::Value>(&fs::read(path).unwrap()).is_ok());
fs::remove_dir_all(home).unwrap();
}
@@ -1861,7 +1800,7 @@ fn agent_builders_repair_wrong_managed_node_types() {
}
#[test]
fn build_agent_updates_rebuilds_invalid_json5_toml_and_yaml() {
fn build_agent_updates_rejects_invalid_json5_toml_and_yaml() {
let home = agent_test_home("invalid-formats");
let models = test_agent_models(&["model-a"]);
let codex_models = test_codex_models(&["model-a"]);
@@ -1880,9 +1819,9 @@ fn build_agent_updates_rebuilds_invalid_json5_toml_and_yaml() {
"model-a",
&models,
(client == AgentClient::Codex).then_some(codex_models.as_str()),
)
.unwrap();
assert!(!updates[0].after.trim().is_empty());
);
assert!(updates.is_err());
assert_eq!(fs::read_to_string(path).unwrap(), "not valid {{{");
}
let hermes = build_hermes_agent_config(
Some("not valid {{{"),
@@ -1891,16 +1830,40 @@ fn build_agent_updates_rebuilds_invalid_json5_toml_and_yaml() {
"model-a",
&models,
)
.or_else(|_| {
build_hermes_agent_config(
None,
"http://127.0.0.1:8317/v1",
DEFAULT_API_KEY,
"model-a",
&models,
)
})
.unwrap();
assert!(!hermes.trim().is_empty());
;
assert!(hermes.is_err());
fs::remove_dir_all(home).unwrap();
}
#[test]
fn connection_state_distinguishes_fresh_repair_invalid_and_ready() {
assert_eq!(agent_connection_state(false, true, Ok(false)), "not-configured");
assert_eq!(agent_connection_state(false, true, Ok(true)), "needs-update");
assert_eq!(agent_connection_state(true, true, Ok(true)), "configured");
assert_eq!(agent_connection_state(false, false, Ok(false)), "invalid");
assert_eq!(agent_connection_state(false, true, Err("read failed".into())), "invalid");
}
#[test]
fn codex_connection_evidence_survives_port_key_and_partial_config_changes() {
let home = agent_test_home("connection-evidence");
let paths = agent_config_paths(AgentClient::Codex, &home);
assert!(!agent_has_connection_evidence(AgentClient::Codex, &paths).unwrap());
fs::create_dir_all(paths[0].parent().unwrap()).unwrap();
fs::write(&paths[0], "model_provider = 'personal'\ncustom_note = 'cpa-gui'\n").unwrap();
assert!(!agent_has_connection_evidence(AgentClient::Codex, &paths).unwrap());
apply_agent_configuration(AgentClient::Codex, &home, 8317, "old-key", "gpt-one", &test_agent_models(&["gpt-one"]), Some(&test_codex_models(&["gpt-one"]))).unwrap();
for (port, key) in [(8318, "old-key"), (8317, "new-key")] {
let (configured, _, _) = inspect_codex_agent_config(&paths[0], port, key).unwrap();
assert!(!configured);
assert_eq!(agent_connection_state(configured, true, agent_has_connection_evidence(AgentClient::Codex, &paths)), "needs-update");
}
fs::remove_file(home.join(".codex/auth.json")).unwrap();
assert!(!inspect_codex_agent_config(&paths[0],8317,"old-key").unwrap().0);
assert!(agent_has_connection_evidence(AgentClient::Codex, &paths).unwrap());
fs::write(&paths[0], "[model_providers.cpa-gui]\nbase_url = 'http://127.0.0.1:1234/v1'\n").unwrap();
assert!(agent_has_connection_evidence(AgentClient::Codex, &paths).unwrap());
fs::write(&paths[0], "{{broken").unwrap();
assert!(agent_has_connection_evidence(AgentClient::Codex, &paths).is_err());
fs::remove_dir_all(home).unwrap();
}
+31 -8
View File
@@ -809,7 +809,7 @@ export const jaOverrides = {
'agents.status.invalid': '設定が無効',
'agents.status.applied': '適用済み',
'agents.status.unconfigured': '未設定',
"agents.history.button": "設定履歴を復元",
"agents.history.button": "設定履歴",
"agents.history.description": "ソフトウェア管理の項目のみ復元し、他の設定を保持します。復元前に現在の設定を保存し、フォームを更新します。",
"agents.history.versions": "履歴",
"agents.history.empty": "履歴はありません。設定更新時に自動保存されます。",
@@ -826,7 +826,7 @@ export const jaOverrides = {
"agents.history.restore": "この履歴を復元",
"agents.history.before": "{source}前のバックアップ",
"agents.history.source.update": "設定更新",
"agents.history.source.default": "既定設定",
"agents.history.source.default": "設定の再構築",
"agents.history.source.clear": "設定消去",
"agents.history.source.restore": "復元",
"agents.history.source.sync": "自動同期",
@@ -912,7 +912,30 @@ export const jaOverrides = {
'agents.model.readingAvailable': '利用可能なモデルを読み込んでいます',
'agents.model.current': '現在適用中:{model}',
'agents.model.firstSelection': '利用可能なモデル {count} 件。初回は先頭を選択します',
'agents.modify.title': '設定変更',
"agents.modify.connected": "現在の接続設定:CPA",
"agents.modify.disconnected": "現在の接続設定:CPA 未設定",
"agents.modify.connectionChecking": "現在の接続設定:確認中…",
"agents.modify.connectionUnknown": "現在の接続設定:判定できません。設定を確認してください",
"agents.modify.description": "選択したモデルと認証方式をローカル設定に書き込みます。書き込み前に自動バックアップします。",
"agents.modify.authApiKeyHint": "CPA の API を使用して接続し、より安定した接続状態を維持します。",
"agents.modify.authOAuthHint": "CPA 経由で接続し、アカウントのログイン状態を保持して Codex の全サービスを利用できます。事前に Codex でアカウントにログインしてください。",
"agents.modify.authHelpLabel": "説明",
"agents.modify.authHelpShow": "選び方",
"agents.modify.authHelpHide": "説明を閉じる",
"agents.modify.authHelp": "接続方式の説明",
"agents.modify.connect": "ワンクリック接続",
"agents.modify.repair": "接続設定を修復",
"agents.modify.connectionRepair": "現在の接続設定:CPA 設定の修復が必要です",
"agents.modify.authMethod": "接続方式",
"agents.modify.authApiKey": "API キー",
"agents.modify.authOAuth": "OAuth 設定方式",
"agents.modify.matches": "保存済み",
"agents.modify.pending": "未保存",
"agents.modify.checking": "設定を読み込み中…",
"agents.modify.invalidState": "現在の設定を解析できません",
"agents.modify.more": "その他の操作",
"agents.modify.moreDescription": "再構築やクリアは独自設定を削除します。必要な場合のみ使用してください。",
"agents.modify.title": "{name} の設定を書き込む",
'agents.modify.oauthConfiguration': 'OAuth ログイン設定方式',
'agents.claudeDesktopMapping.title': 'Claude Desktop モデル → 上流モデル',
'agents.claudeDesktopMapping.description': '役割ごとに上流モデルとコンテキスト機能を選択します。対応モデルでのみ 1M を有効にしてください。',
@@ -942,10 +965,10 @@ export const jaOverrides = {
'agents.modify.apply': '設定変更を適用',
'agents.modify.update': '設定を更新',
'agents.modify.close': '設定変更を終了',
'agents.modify.default': 'デフォルト設定',
"agents.modify.default": "設定を再構築",
'agents.modify.clear': '設定を消去',
'agents.modify.invalid': '現在の設定を解析できません。「設定変更を適用」は最小の有効な設定を再構築し、「デフォルト設定」はファイル全体をリセットします。',
'agents.modify.zcodeRestart': '設定の適用または終了後、ZCode を完全終了して再起動してください。デフォルトモデルとコンテキスト長は新しいセッションで有効になります。',
"agents.modify.invalid": "通常の更新は停止しています。設定ファイルを修正するか、「設定を再構築」で再生成してください。",
"agents.modify.zcodeRestart": "設定更新後、ZCode を完全終了して再起動してください。デフォルトモデルとコンテキスト長は新しいセッションで有効になります。",
'agents.pi.installTitle': 'Pi CLIProxyAPI プロバイダー',
'agents.pi.installDescription': 'プラグインをインストールまたは更新し、CPA 認証情報、既定のプロバイダー、既定のモデルを修復します',
'agents.pi.install': 'Pi プラグインをインストール',
@@ -957,9 +980,9 @@ export const jaOverrides = {
'agents.pi.repairing': '設定を適用中',
'agents.pi.uninstall': 'Pi プラグインをアンインストール',
'agents.pi.uninstalling': 'プラグインをアンインストール中',
'agents.default.title': 'デフォルト設定にリセット',
"agents.default.title": "設定を再構築",
'agents.default.description': '選択中のモデルを使って {name} の関連設定ファイルをすべて書き直し、その他のユーザー内容を削除します。操作前に履歴を保存します。履歴から復元できるのは管理対象フィールドのみで、削除された独自設定は復元されません。',
'agents.default.confirm': 'デフォルト設定を書き込む',
"agents.default.confirm": "再構築を確認",
'agents.clear.title': 'Codex 設定を消去',
'agents.clear.description': 'Codex 設定ディレクトリの auth.json と config.toml を完全に削除します。操作前に履歴を保存します。履歴から復元できるのは管理対象フィールドのみで、削除された独自設定は復元されません。',
'agents.clear.confirm': '設定を消去',
+31 -8
View File
@@ -809,7 +809,7 @@ export const en: Record<MessageKey, string> = {
'agents.status.invalid': 'Invalid Configuration',
'agents.status.applied': 'Applied',
'agents.status.unconfigured': 'Not Configured',
"agents.history.button": "Restore configuration history",
"agents.history.button": "Configuration History",
"agents.history.description": "Restore only software-managed fields and preserve other settings. Current configuration is backed up first; form drafts refresh after restoring.",
"agents.history.versions": "Versions",
"agents.history.empty": "No history yet. Updating configuration saves versions automatically.",
@@ -826,7 +826,7 @@ export const en: Record<MessageKey, string> = {
"agents.history.restore": "Restore this version",
"agents.history.before": "Before {source}",
"agents.history.source.update": "Update",
"agents.history.source.default": "Default configuration",
"agents.history.source.default": "Rebuild configuration",
"agents.history.source.clear": "Clear configuration",
"agents.history.source.restore": "Restore",
"agents.history.source.sync": "Automatic sync",
@@ -930,7 +930,30 @@ export const en: Record<MessageKey, string> = {
'agents.model.readingAvailable': 'Loading available models',
'agents.model.current': 'Currently applied: {model}',
'agents.model.firstSelection': '{count} models available; the first is selected by default',
'agents.modify.title': 'Configuration Changes',
"agents.modify.connected": "Current integration: CPA",
"agents.modify.disconnected": "Current integration: CPA not configured",
"agents.modify.connectionChecking": "Current integration: checking…",
"agents.modify.connectionUnknown": "Current integration: unknown; check configuration",
"agents.modify.description": "Write the selected model and authentication settings to local configuration. A backup is saved first.",
"agents.modify.authApiKeyHint": "Connect using the CPA API for a more stable connection.",
"agents.modify.authOAuthHint": "Connect through CPA while retaining your account sign-in state and access to the full Codex service. Sign in to your account in Codex first.",
"agents.modify.authHelpLabel": "Details",
"agents.modify.authHelpShow": "How to choose",
"agents.modify.authHelpHide": "Hide details",
"agents.modify.authHelp": "About connection methods",
"agents.modify.connect": "Connect in One Click",
"agents.modify.repair": "Repair Connection",
"agents.modify.connectionRepair": "Current integration: CPA configuration needs repair",
"agents.modify.authMethod": "Connection method",
"agents.modify.authApiKey": "API key",
"agents.modify.authOAuth": "OAuth configuration mode",
"agents.modify.matches": "Saved",
"agents.modify.pending": "Unsaved changes",
"agents.modify.checking": "Reading configuration…",
"agents.modify.invalidState": "Cannot parse current configuration",
"agents.modify.more": "More actions",
"agents.modify.moreDescription": "Rebuilding or clearing removes custom settings. Use these actions only when needed.",
"agents.modify.title": "Write {name} Configuration",
'agents.modify.oauthConfiguration': 'OAuth sign-in configuration mode',
'agents.oauthLoginRequired.title': 'Codex sign-in required',
'agents.oauthLoginRequired.enableDescription': 'No valid Codex sign-in credentials were found, so OAuth configuration mode cannot be enabled. Sign in to your ChatGPT account in the Codex app. If the Codex app cannot trigger sign-in, use',
@@ -942,10 +965,10 @@ export const en: Record<MessageKey, string> = {
'agents.modify.apply': 'Apply Configuration Changes',
'agents.modify.update': 'Update Configuration',
'agents.modify.close': 'Close Configuration Changes',
'agents.modify.default': 'Default Configuration',
"agents.modify.default": "Rebuild Configuration",
'agents.modify.clear': 'Clear Configuration',
'agents.modify.invalid': 'The current configuration cannot be parsed. Apply Configuration Changes rebuilds a minimal valid configuration; Default Configuration resets the whole file.',
'agents.modify.zcodeRestart': 'Fully quit and restart ZCode after applying or closing the configuration; the default model and context length take effect in new sessions.',
"agents.modify.invalid": "Normal updates are stopped. Fix the configuration file, or regenerate it using Rebuild Configuration.",
"agents.modify.zcodeRestart": "Fully quit and restart ZCode after updating configuration; the default model and context length take effect in new sessions.",
'agents.pi.installTitle': 'Pi CLIProxyAPI provider',
'agents.pi.installDescription': 'Install or update the plugin and repair CPA credentials, the default provider, and the default model.',
'agents.pi.install': 'Install Pi Plugin',
@@ -957,9 +980,9 @@ export const en: Record<MessageKey, string> = {
'agents.pi.repairing': 'Applying configuration',
'agents.pi.uninstall': 'Uninstall Pi Plugin',
'agents.pi.uninstalling': 'Uninstalling plugin',
'agents.default.title': 'Reset to Default Configuration',
"agents.default.title": "Rebuild Configuration",
'agents.default.description': 'This rewrites every related {name} configuration file using the selected model and deletes all other user content in those files. A history snapshot is saved first. History recovery restores only managed fields, not deleted custom settings.',
'agents.default.confirm': 'Write Default Configuration',
"agents.default.confirm": "Confirm Rebuild",
'agents.clear.title': 'Clear Codex Configuration',
'agents.clear.description': 'This permanently deletes auth.json and config.toml from the Codex configuration directory. A history snapshot is saved first. History recovery restores only managed fields, not deleted custom settings.',
'agents.clear.confirm': 'Clear Configuration',
+31 -8
View File
@@ -808,7 +808,7 @@ export const zhCN = {
'agents.status.invalid': '配置无效',
'agents.status.applied': '已应用',
'agents.status.unconfigured': '尚未配置',
"agents.history.button": "恢复历史配置",
"agents.history.button": "配置历史",
"agents.history.description": "仅恢复软件管理的配置字段,保留其他自定义设置。恢复前会自动备份当前配置;当前表单草稿将按恢复结果刷新。",
"agents.history.versions": "历史版本",
"agents.history.empty": "暂无配置历史。更新配置后会自动保留版本。",
@@ -825,7 +825,7 @@ export const zhCN = {
"agents.history.restore": "恢复此版本",
"agents.history.before": "{source}前备份",
"agents.history.source.update": "更新配置",
"agents.history.source.default": "默认配置",
"agents.history.source.default": "重建配置",
"agents.history.source.clear": "清空配置",
"agents.history.source.restore": "恢复配置",
"agents.history.source.sync": "自动同步",
@@ -929,7 +929,30 @@ export const zhCN = {
'agents.model.readingAvailable': '正在读取可用模型',
'agents.model.current': '当前已应用:{model}',
'agents.model.firstSelection': '{count} 个可用模型,首次默认选择第一项',
'agents.modify.title': '配置修改',
"agents.modify.connected": "当前接入:CPA",
"agents.modify.disconnected": "当前接入:未接入 CPA",
"agents.modify.connectionChecking": "当前接入:检测中…",
"agents.modify.connectionUnknown": "当前接入:无法判断,请检查配置",
"agents.modify.description": "将所选模型和认证方式写入本机配置,写入前自动备份。",
"agents.modify.authApiKeyHint": "使用 CPA 的API接入,状态更稳定。",
"agents.modify.authOAuthHint": "仍通过 CPA 接入,保留账号登录状态,能使用完整的Codex服务,需在Codex中提前登录账号。",
"agents.modify.authHelpLabel": "说明",
"agents.modify.authHelpShow": "如何选择",
"agents.modify.authHelpHide": "收起说明",
"agents.modify.authHelp": "接入方式说明",
"agents.modify.connect": "一键接入",
"agents.modify.repair": "修复接入",
"agents.modify.connectionRepair": "当前接入:CPA 配置需要修复",
"agents.modify.authMethod": "接入方式",
"agents.modify.authApiKey": "API 密钥",
"agents.modify.authOAuth": "OAuth配置方式",
"agents.modify.matches": "已保存",
"agents.modify.pending": "未保存",
"agents.modify.checking": "正在读取配置…",
"agents.modify.invalidState": "当前配置无法解析",
"agents.modify.more": "更多操作",
"agents.modify.moreDescription": "重建或清空会移除自定义设置,请按需使用。",
"agents.modify.title": "写入 {name} 配置",
'agents.modify.oauthConfiguration': 'OAuth登录配置方式',
'agents.oauthLoginRequired.title': '需要登录 Codex',
'agents.oauthLoginRequired.enableDescription': '未检测到有效的 Codex 登录凭据,无法开启 OAuth 配置模式。请先在 Codex 应用中登录 ChatGPT 账号,如果 Codex 应用无法触发登录,请先使用',
@@ -941,10 +964,10 @@ export const zhCN = {
'agents.modify.apply': '应用配置修改',
'agents.modify.update': '更新配置',
'agents.modify.close': '关闭配置修改',
'agents.modify.default': '默认配置',
"agents.modify.default": "重建配置",
'agents.modify.clear': '清空配置',
'agents.modify.invalid': '当前配置无法解析。点击“应用配置修改”会重建最小合法配置;“默认配置”会重置整个文件。',
'agents.modify.zcodeRestart': '应用或关闭配置后,请完全退出并重新启动 ZCode;默认模型和上下文长度会在新会话中生效。',
"agents.modify.invalid": "普通更新已停止。请修复配置文件,或通过“重建配置”重新生成。",
"agents.modify.zcodeRestart": "更新配置后,请完全退出并重新启动 ZCode;默认模型和上下文长度会在新会话中生效。",
'agents.pi.installTitle': 'Pi CLIProxyAPI 提供方',
'agents.pi.installDescription': '安装或更新插件,并修复 CPA 凭据、默认提供方与默认模型',
'agents.pi.install': '安装 Pi 插件',
@@ -956,9 +979,9 @@ export const zhCN = {
'agents.pi.repairing': '正在应用配置',
'agents.pi.uninstall': '卸载 Pi 插件',
'agents.pi.uninstalling': '正在卸载插件',
'agents.default.title': '重置为默认配置',
"agents.default.title": "重建配置",
'agents.default.description': '这会使用当前选中模型重写 {name} 的全部相关配置文件,并删除其中其他用户内容。操作前会保存历史备份;历史恢复仅恢复软件管理的字段,不能恢复被清除的其他自定义内容。',
'agents.default.confirm': '确认写入默认配置',
"agents.default.confirm": "确认重建配置",
'agents.clear.title': '清空 Codex 配置',
'agents.clear.description': '这会永久删除 Codex 配置目录中的 auth.json 和 config.toml。操作前会保存历史备份;历史恢复仅恢复软件管理的字段,不能恢复被清除的其他自定义内容。',
'agents.clear.confirm': '确认清空配置',
+17
View File
@@ -0,0 +1,17 @@
.agent-history-modal { width: min(1000px, calc(100vw - 40px)); max-height: calc(100vh - 40px); overflow: auto; box-sizing: border-box; padding: 22px; border: 1px solid var(--theme-e3e1db); border-radius: 16px; background: var(--theme-fffdf8); color: var(--theme-2d2a26); box-shadow: 0 24px 80px #0005; }
.agent-history-modal::backdrop { background: #0007; }
.agent-history-modal header, .agent-history-modal footer { display: flex; gap: 12px; align-items: center; justify-content: space-between; }
.agent-history-modal h2 { display: flex; align-items: center; gap: 8px; font-size: 18px; margin: 0; }
.agent-history-modal p { font-size: 13px; line-height: 1.6; }
.agent-history-modal footer { justify-content: flex-end; margin-top: 16px; }
.agent-history-columns { display: grid; grid-template-columns: 240px minmax(0, 1fr); gap: 16px; margin-top: 16px; }
.agent-history-columns nav { max-height: 52vh; overflow: auto; display: flex; flex-direction: column; gap: 8px; }
.agent-history-columns nav button { display: grid; gap: 5px; text-align: left; border: 1px solid var(--theme-e3e1db); border-radius: 8px; padding: 12px; color: inherit; background: transparent; cursor: pointer; }
.agent-history-columns nav button.active { border-color: var(--theme-4f825c); background: var(--theme-eef7f0); }
.agent-history-columns nav span, .agent-history-columns nav small { font-size: 12px; color: var(--theme-7a746c); }
.agent-history-changes { min-width: 0; max-height: 52vh; overflow: auto; }
.agent-history-changes table { width: 100%; table-layout: fixed; border-collapse: collapse; font-size: 12px; }
.agent-history-changes td, .agent-history-changes th { text-align: left; vertical-align: top; padding: 10px 8px; border-bottom: 1px solid var(--theme-e3e1db); overflow-wrap: anywhere; white-space: pre-wrap; }
.agent-history-changes td small, .agent-history-changes td code { display: block; }
.agent-history-changes td small { color: var(--theme-7a746c); margin-bottom: 5px; }
@media (max-width: 760px) { .agent-history-columns { grid-template-columns: 1fr; } .agent-history-columns nav { max-height: 180px; } }
+81
View File
@@ -0,0 +1,81 @@
import { useEffect, useRef, useState } from 'react';
import { invoke } from '@tauri-apps/api/core';
import { History, LoaderCircle, X } from 'lucide-react';
import { useI18n } from '../i18n';
import type { MessageKey } from '../i18n/resources';
import './AgentConfigHistoryDialog.css';
type Version = { id: string; createdAt: string; source: string; model: string | null; fileCount: number };
type Listing = { versions: Version[]; warnings: string[] };
type Preview = { revision: string; differences: { file: string; field: string; before: string; after: string }[] };
const sourceKeys: Record<string, MessageKey> = {
'plugin-install': 'agents.pi.install', 'plugin-update': 'agents.pi.update', 'plugin-remove': 'agents.pi.uninstall',
update: 'agents.history.source.update', default: 'agents.history.source.default',
clear: 'agents.history.source.clear', restore: 'agents.history.source.restore',
sync: 'agents.history.source.sync', legacy: 'agents.history.source.legacy',
};
export function AgentConfigHistoryDialog({ client, onClose, onRestored }: {
client: string; onClose: () => void; onRestored: () => Promise<void>;
}) {
const { t, formatDate } = useI18n();
const dialog = useRef<HTMLDialogElement>(null);
const [listing, setListing] = useState<Listing>({ versions: [], warnings: [] });
const [selected, setSelected] = useState<Version | null>(null);
const [preview, setPreview] = useState<Preview | null>(null);
const [busy, setBusy] = useState(true);
const [error, setError] = useState('');
useEffect(() => {
let disposed = false;
dialog.current?.showModal();
void invoke<Listing>('list_agent_config_history', { client })
.then((value) => { if (!disposed) setListing(value); })
.catch((cause) => { if (!disposed) setError(String(cause)); })
.finally(() => { if (!disposed) setBusy(false); });
return () => { disposed = true; };
}, [client]);
const choose = async (version: Version) => {
setBusy(true); setError(''); setSelected(version); setPreview(null);
try { setPreview(await invoke<Preview>('preview_agent_config_history', { client, id: version.id })); }
catch (cause) { setError(String(cause)); }
finally { setBusy(false); }
};
const restore = async () => {
if (!selected || !preview) return;
setBusy(true); setError('');
try {
await invoke('restore_agent_config_history', { client, id: selected.id, revision: preview.revision });
await onRestored(); onClose();
} catch (cause) { setError(String(cause)); setPreview(null); }
finally { setBusy(false); }
};
const source = (value: string) => {
const before = value.startsWith('before-');
const label = t(sourceKeys[before ? value.slice(7) : value] ?? 'agents.history.source.update');
return before ? t('agents.history.before', { source: label }) : label;
};
return <dialog className="agent-history-modal" ref={dialog} aria-labelledby="agent-history-title" onCancel={(event) => { event.preventDefault(); if (!busy) onClose(); }}>
<header><h2 id="agent-history-title"><History size={20} />{t('agents.history.button')}</h2>
<button type="button" className="secondary-button" onClick={onClose} disabled={busy} aria-label={t('common.cancel')}><X size={18} /></button></header>
<p>{t('agents.history.description')}</p>
{listing.warnings.map((warning) => <p key={warning} className="agent-inline-message warning">{warning}</p>)}
<div className="agent-history-columns">
<nav aria-label={t('agents.history.versions')}>
{!listing.versions.length && !busy && <p>{t('agents.history.empty')}</p>}
{listing.versions.map((version) => <button type="button" key={version.id} disabled={busy} className={selected?.id === version.id ? 'active' : ''} onClick={() => void choose(version)}>
<strong>{formatDate(version.createdAt, { dateStyle: 'short', timeStyle: 'medium' })}</strong>
<span>{source(version.source)}</span>
<small>{version.model ?? '—'} · {t('agents.history.files', { count: version.fileCount })}</small>
</button>)}
</nav>
<section className="agent-history-changes" aria-label={t('agents.history.preview')}>
{busy ? <p role="status"><LoaderCircle size={16} className="spin" /> {t('agents.history.loading')}</p> : !preview ? <p>{t('agents.history.select')}</p> : preview.differences.length === 0 ? <p>{t('agents.history.unchanged')}</p> :
<table><thead><tr><th>{t('agents.history.field')}</th><th>{t('agents.history.current')}</th><th>{t('agents.history.target')}</th></tr></thead>
<tbody>{preview.differences.map((diff) => <tr key={`${diff.file}:${diff.field}`}><td><small title={diff.file}>{diff.file.split(/[\\/]/).pop()}</small><code>{diff.field}</code></td><td>{diff.before}</td><td>{diff.after}</td></tr>)}</tbody></table>}
</section>
</div>
{error && <p className="agent-inline-message error" role="alert">{error}</p>}
<footer><button type="button" className="secondary-button" disabled={busy} onClick={onClose}>{t('common.cancel')}</button>
<button type="button" className="primary-button" disabled={busy || !preview || !preview.differences.length} onClick={() => void restore()}>{t('agents.history.restore')}</button></footer>
</dialog>;
}
+124 -137
View File
@@ -48,7 +48,6 @@ import {
resolveAgentModelSelection,
} from '../services/agentModelPicker';
import {
resolveAgentConfigurationAction,
resolveAgentModelMappingsDraftSourceForClient,
sameAgentModel,
sameAgentModelMappings,
@@ -69,6 +68,7 @@ import type { ModelOption } from '../services/modelService';
import { getCurrentLocale, translate, useI18n } from '../i18n';
import { CodexSessionsPanel } from './CodexSessionsPanel';
import { CodexModelCatalogDialog } from './CodexModelCatalogDialog';
import { AgentConfigHistoryDialog } from './AgentConfigHistoryDialog';
type AgentClientId =
| 'claude-code'
@@ -100,6 +100,7 @@ type AgentConfigStatus = {
pluginVersion: string | null;
configValid: boolean;
configured: boolean;
connectionState: 'configured' | 'not-configured' | 'needs-update' | 'invalid';
configurationSynchronized: boolean;
currentModel: string | null;
oauthConfiguration: boolean;
@@ -126,7 +127,8 @@ type DeepSeekHarnessProcessStatus = {
};
type AgentConfigActionResult = {
outcome: 'applied' | 'default';
outcome: 'applied' | 'default' | 'updated' | 'unchanged';
historyVersion: string | null;
enabled: boolean;
model: string | null;
changedFiles: string[];
@@ -672,13 +674,16 @@ export function AgentsPage({ embedded = false, onConfigurationApplied }: AgentsP
const [loading, setLoading] = useState(true);
const [modelLoading, setModelLoading] = useState(false);
const [busyAction, setBusyAction] = useState<
'apply' | 'close-config' | 'default' | 'clear' | 'install-pi' | 'update-pi' | 'repair-pi' | 'uninstall-pi' | 'oauth-check' | 'directory' | 'launch' | 'launch-cli' | 'launch-app' | 'restart-app' | 'stop-deepseek' | null
'apply' | 'default' | 'clear' | 'install-pi' | 'update-pi' | 'repair-pi' | 'uninstall-pi' | 'oauth-check' | 'directory' | 'launch' | 'launch-cli' | 'launch-app' | 'restart-app' | 'stop-deepseek' | null
>(null);
const busy = busyAction !== null;
const [detectionError, setDetectionError] = useState('');
const [modelError, setModelError] = useState('');
const [modelSelectionError, setModelSelectionError] = useState('');
const [configurationError, setConfigurationError] = useState('');
const [configurationNotice, setConfigurationNotice] = useState('');
const [historyOpen, setHistoryOpen] = useState(false);
const [connectionHelpOpen, setConnectionHelpOpen] = useState(false);
const [defaultError, setDefaultError] = useState('');
const [defaultConfirmOpen, setDefaultConfirmOpen] = useState(false);
const [clearError, setClearError] = useState('');
@@ -848,6 +853,9 @@ export function AgentsPage({ embedded = false, onConfigurationApplied }: AgentsP
setActiveSubpage(DEFAULT_AGENT_SUBPAGE);
setModelSelectionError('');
setConfigurationError('');
setConfigurationNotice('');
setHistoryOpen(false);
setConnectionHelpOpen(false);
setDefaultError('');
setDefaultConfirmOpen(false);
setClearError('');
@@ -966,16 +974,19 @@ export function AgentsPage({ embedded = false, onConfigurationApplied }: AgentsP
selectedModel,
]);
const appliedModel = activeStatus?.appliedModel ?? activeStatus?.currentModel ?? '';
const modelDraftChanged = !isClaudeModelMappingClient && Boolean(
selectedModel.trim()
&& appliedModel.trim()
&& !sameAgentModel(selectedModel, appliedModel),
);
const appliedClaudeModelMappings = (selected === 'claude-code'
const connectionState = activeStatus?.connectionState ?? 'invalid';
const configurationActionLabel = t(connectionState === 'not-configured' ? 'agents.modify.connect'
: connectionState === 'needs-update' ? 'agents.modify.repair' : 'agents.modify.update');
const configurationWriteBlocked = loading || !activeStatus || connectionState === 'invalid';
const appliedModel = activeStatus?.currentModel ?? activeStatus?.appliedModel ?? '';
const appliedMappings = selected === 'claude-code'
? activeStatus?.claudeCodeModelMappings
: activeStatus?.claudeDesktopModelMappings)
?? createClaudeModelMappings(appliedModel);
: activeStatus?.claudeDesktopModelMappings;
const formMatchesConfiguration = Boolean(activeStatus?.configValid && activeStatus?.configured)
&& (isClaudeModelMappingClient
? Boolean(appliedMappings && sameAgentModelMappings(claudeModelMappingsDraft, appliedMappings))
: sameAgentModel(selectedModel, appliedModel))
&& (selected !== 'codex' || oauthConfiguration === Boolean(activeStatus?.oauthConfiguration));
const claudeMappingsReady = !isClaudeModelMappingClient
|| claudeMappingRoles.every((role) =>
Boolean(findAgentModel(models, claudeModelMappingsDraft[role.key])),
@@ -986,26 +997,6 @@ export function AgentsPage({ embedded = false, onConfigurationApplied }: AgentsP
&& claudeModelMappingsDraft.autoCompactPct >= 1
&& claudeModelMappingsDraft.autoCompactPct <= 100
);
const claudeMappingDraftChanged = isClaudeModelMappingClient
&& activeStatus?.modificationState === 'applied'
&& !sameAgentModelMappings(
claudeModelMappingsDraft,
appliedClaudeModelMappings,
);
const oauthConfigurationChanged = selected === 'codex'
&& oauthConfiguration !== Boolean(activeStatus?.oauthConfiguration);
const draftChanged = modelDraftChanged || claudeMappingDraftChanged || oauthConfigurationChanged;
const configurationAction = resolveAgentConfigurationAction({
client: selected,
modificationState: activeStatus?.modificationState ?? 'unconfigured',
configurationSynchronized: Boolean(activeStatus?.configurationSynchronized),
selectedModel,
appliedModel,
oauthConfiguration,
appliedOauthConfiguration: Boolean(activeStatus?.oauthConfiguration),
modelMappings: claudeModelMappingsDraft,
appliedModelMappings: appliedClaudeModelMappings,
});
const canEnable = Boolean(
activeStatus?.supportedPlatform
&& activeStatus.installed
@@ -1063,9 +1054,7 @@ export function AgentsPage({ embedded = false, onConfigurationApplied }: AgentsP
void (activeStatus?.pluginInstalled ? repairPiProvider() : installPiProvider());
return;
}
void (configurationAction === 'close'
? closeConfigurationChanges()
: applyConfigurationChanges());
void applyConfigurationChanges();
};
const reloadStatusesAfterAction = async () => {
@@ -1078,6 +1067,8 @@ export function AgentsPage({ embedded = false, onConfigurationApplied }: AgentsP
};
const selectModel = (value: string) => {
setConfigurationNotice('');
setClearNotice('');
const model = findAgentModel(models, value);
if (!model) return;
setModelSelectionError('');
@@ -1092,7 +1083,8 @@ export function AgentsPage({ embedded = false, onConfigurationApplied }: AgentsP
const model = findAgentModel(models, value);
if (!model) return;
if (isClaudeModelMappingClient) {
claudeModelMappingsDirtyRef.current[selected] = true;
setConfigurationNotice('');
claudeModelMappingsDirtyRef.current[selected] = true;
setClaudeModelMappingsDraftByClient((current) => ({
...current,
[selected]: {
@@ -1112,6 +1104,7 @@ export function AgentsPage({ embedded = false, onConfigurationApplied }: AgentsP
) => {
const model = findAgentModel(models, value);
if (!model || !isClaudeModelMappingClient) return;
setConfigurationNotice('');
claudeModelMappingsDirtyRef.current[selected] = true;
setModelSelectionError('');
setClaudeModelMappingsDraftByClient((current) => ({
@@ -1125,6 +1118,7 @@ export function AgentsPage({ embedded = false, onConfigurationApplied }: AgentsP
enabled: boolean,
) => {
if (!isClaudeModelMappingClient) return;
setConfigurationNotice('');
claudeModelMappingsDirtyRef.current[selected] = true;
setClaudeModelMappingsDraftByClient((current) => {
const next = { ...current[selected], [preference]: enabled };
@@ -1143,6 +1137,7 @@ export function AgentsPage({ embedded = false, onConfigurationApplied }: AgentsP
value: number,
) => {
if (selected !== 'claude-code') return;
setConfigurationNotice('');
claudeModelMappingsDirtyRef.current[selected] = true;
setClaudeModelMappingsDraftByClient((current) => ({
...current,
@@ -1152,6 +1147,7 @@ export function AgentsPage({ embedded = false, onConfigurationApplied }: AgentsP
const changeClaudeCodeAutoCompactDisabled = (disabled: boolean) => {
if (selected !== 'claude-code') return;
setConfigurationNotice('');
claudeModelMappingsDirtyRef.current[selected] = true;
setClaudeModelMappingsDraftByClient((current) => ({
...current,
@@ -1180,7 +1176,8 @@ export function AgentsPage({ embedded = false, onConfigurationApplied }: AgentsP
disableAutoCompact: currentClientDraft.disableAutoCompact,
};
if (!sameAgentModelMappings(currentClientDraft, next)) {
claudeModelMappingsDirtyRef.current[selected] = true;
setConfigurationNotice('');
claudeModelMappingsDirtyRef.current[selected] = true;
}
return { ...current, [selected]: next };
});
@@ -1238,6 +1235,8 @@ export function AgentsPage({ embedded = false, onConfigurationApplied }: AgentsP
};
const changeOauthConfiguration = async (enabled: boolean) => {
setConfigurationNotice('');
setClearNotice('');
if (!enabled) {
setOauthConfigurationDraft(false);
return;
@@ -1265,8 +1264,9 @@ export function AgentsPage({ embedded = false, onConfigurationApplied }: AgentsP
: requireSelectedModel();
if (!model) return;
setBusyAction('apply');
setConfigurationNotice('');
try {
await invoke<AgentConfigActionResult>('apply_agent_config', {
const result = await invoke<AgentConfigActionResult>('update_agent_config', {
client: selected,
model,
oauthConfiguration,
@@ -1278,6 +1278,7 @@ export function AgentsPage({ embedded = false, onConfigurationApplied }: AgentsP
}
await reloadStatusesAfterAction();
setOauthConfigurationDraft(null);
setConfigurationNotice(t(result.outcome === 'unchanged' ? 'agents.history.unchanged' : 'agents.history.updated'));
onConfigurationApplied?.();
} catch (requestError) {
if (!handleOAuthLoginError(requestError, 'apply')) {
@@ -1327,8 +1328,9 @@ export function AgentsPage({ embedded = false, onConfigurationApplied }: AgentsP
setBusyAction('repair-pi');
setConfigurationError('');
try {
await invoke<AgentConfigActionResult>('repair_pi_provider', { model });
const result = await invoke<AgentConfigActionResult>('repair_pi_provider', { model });
await reloadStatusesAfterAction();
setConfigurationNotice(t(result.outcome === 'unchanged' ? 'agents.history.unchanged' : 'agents.history.updated'));
onConfigurationApplied?.();
} catch (requestError) {
setConfigurationError(String(requestError));
@@ -1351,24 +1353,6 @@ export function AgentsPage({ embedded = false, onConfigurationApplied }: AgentsP
}
};
const closeConfigurationChanges = async () => {
const retainedOauthConfiguration = selected === 'codex' ? oauthConfiguration : null;
setConfigurationError('');
setBusyAction('close-config');
try {
await invoke<AgentConfigActionResult>('close_agent_config_modification', { client: selected });
if (isClaudeModelMappingClient) {
claudeModelMappingsDirtyRef.current[selected] = false;
}
await reloadStatusesAfterAction();
setOauthConfigurationDraft(retainedOauthConfiguration);
} catch (requestError) {
setConfigurationError(String(requestError));
} finally {
setBusyAction(null);
}
};
const resetConfigurationToDefault = async () => {
const claudeModelMappings = requireClaudeModelMappings();
if (isClaudeModelMappingClient && !claudeModelMappings) return;
@@ -1690,17 +1674,14 @@ export function AgentsPage({ embedded = false, onConfigurationApplied }: AgentsP
type="button"
className="primary-button"
onClick={runEmbeddedPrimaryAction}
disabled={busy || (isPiClient ? !canEnable : configurationAction !== 'close' && !canEnable)}
disabled={busy || !canEnable || configurationWriteBlocked}
>
{busyAction ? <LoaderCircle size={16} className="spin" /> : null}
{isPiClient
? activeStatus?.pluginInstalled ? t('agents.pi.repair') : t('agents.pi.install')
: configurationAction === 'update'
? t('agents.modify.update')
: configurationAction === 'close'
? t('agents.modify.close')
: t('agents.modify.apply')}
? activeStatus?.pluginInstalled ? configurationActionLabel : t('agents.pi.install')
: configurationActionLabel}
</button>
<button type="button" className="secondary-button" onClick={() => setHistoryOpen(true)} disabled={busy}>{t('agents.history.button')}</button>
<button
type="button"
className={isDeepSeekHarnessClient && deepSeekHarnessProcessStatus.running
@@ -1767,6 +1748,20 @@ export function AgentsPage({ embedded = false, onConfigurationApplied }: AgentsP
role="tabpanel"
aria-labelledby="agent-subpage-tab-core"
>
{!isPiClient ? <div className="agent-settings-toolbar">
<div className="agent-settings-title">
<strong>{activeDefinition.name}</strong>
<p className={`agent-connection-state ${activeStatus?.configured ? 'connected' : ''}`} role="status">
{t(loading || !activeStatus
? 'agents.modify.connectionChecking'
: connectionState === 'invalid'
? 'agents.modify.connectionUnknown'
: connectionState === 'needs-update' ? 'agents.modify.connectionRepair'
: connectionState === 'configured' ? 'agents.modify.connected' : 'agents.modify.disconnected')}
</p>
</div>
</div> : null}
<div className={`agent-status-grid ${hasIndependentCliAndApp ? 'dual-install-status-grid' : isPiClient ? 'pi-status-grid' : ''}`}>
<div>
<span><BadgeCheck size={14} />{t('agents.installStatus')}</span>
@@ -1845,6 +1840,40 @@ export function AgentsPage({ embedded = false, onConfigurationApplied }: AgentsP
{modelHint}
</span>
) : null}
{selected === 'codex' ? (
<div className="agent-codex-options">
<div className="agent-auth-method">
<div className="agent-signin-label"><label htmlFor="agent-connection-method">{t('agents.modify.authMethod')}</label>
<button type="button" className="agent-signin-help-toggle" aria-expanded={connectionHelpOpen} aria-controls="agent-signin-hint"
onClick={() => setConnectionHelpOpen((open) => !open)}>
{t(connectionHelpOpen ? 'agents.modify.authHelpHide' : 'agents.modify.authHelpShow')}
<ChevronDown size={14} aria-hidden="true" />
</button>
</div>
<select id="agent-connection-method" aria-label={t('agents.modify.authMethod')} aria-describedby={connectionHelpOpen ? "agent-signin-hint" : undefined} value={oauthConfiguration ? 'oauth' : 'apikey'}
onChange={(event) => void changeOauthConfiguration(event.currentTarget.value === 'oauth')}
disabled={busy}>
<option value="apikey">{t('agents.modify.authApiKey')}</option>
<option value="oauth">{t('agents.modify.authOAuth')}</option>
</select>
</div>
<button
type="button"
className="secondary-button agent-codex-catalog-button"
onClick={() => setCodexCatalogDialogOpen(true)}
disabled={busy}
>
<SlidersHorizontal size={16} />
{t('agents.catalog.button')}
</button>
<div id="agent-signin-hint" className="agent-signin-explanation" hidden={!connectionHelpOpen}>
<dl>
<div><dt>{t('agents.modify.authApiKey')}</dt><dd>{t('agents.modify.authApiKeyHint')}</dd></div>
<div><dt>{t('agents.modify.authOAuth')}</dt><dd>{t('agents.modify.authOAuthHint')}</dd></div>
</dl>
</div>
</div>
) : null}
</section>
) : null}
@@ -1989,6 +2018,7 @@ export function AgentsPage({ embedded = false, onConfigurationApplied }: AgentsP
<div className="agent-section-heading">
<div>
<strong>{t('agents.pi.installTitle')}</strong>
<button type="button" className="secondary-button" onClick={() => setHistoryOpen(true)} disabled={busy}>{t('agents.history.button')}</button>
<span>{t('agents.pi.installDescription')}</span>
</div>
</div>
@@ -2011,16 +2041,16 @@ export function AgentsPage({ embedded = false, onConfigurationApplied }: AgentsP
type="button"
className="secondary-button"
onClick={() => void repairPiProvider()}
disabled={busy || !activeStatus?.installed || !activeStatus.supportedPlatform || !activeStatus.pluginInstalled || !selectedModelOption}
disabled={busy || !activeStatus?.installed || !activeStatus.supportedPlatform || !activeStatus.pluginInstalled || !selectedModelOption || configurationWriteBlocked}
>
{busyAction === 'repair-pi' ? <LoaderCircle size={16} className="spin" /> : <Wrench size={16} />}
{busyAction === 'repair-pi' ? t('agents.pi.repairing') : t('agents.pi.repair')}
{configurationActionLabel}
</button>
<button
type="button"
className="secondary-button"
onClick={() => void updatePiProvider()}
disabled={busy || !activeStatus?.installed || !activeStatus.supportedPlatform || !activeStatus.pluginInstalled || !selectedModelOption}
disabled={busy || !activeStatus?.installed || !activeStatus.supportedPlatform || !activeStatus.pluginInstalled || !selectedModelOption || configurationWriteBlocked}
>
{busyAction === 'update-pi' ? <LoaderCircle size={16} className="spin" /> : <RefreshCw size={16} />}
{busyAction === 'update-pi' ? t('agents.pi.updating') : t('agents.pi.update')}
@@ -2034,65 +2064,17 @@ export function AgentsPage({ embedded = false, onConfigurationApplied }: AgentsP
</div>
</section>
) : (
<section className={`agent-core-setting-section agent-modification-actions ${activeStatus?.modificationState === 'applied' ? 'enabled' : ''}`}>
<div className="agent-section-heading">
<div>
<strong>{t('agents.modify.title')}</strong>
{modificationDescription ? <span>{modificationDescription}</span> : null}
</div>
<div className="agent-save-bar">
<div className="agent-save-feedback" aria-live="polite">
{configurationError ? <span className="agent-inline-message error" role="alert">{configurationError}</span>
: <span className="agent-write-state">{configurationNotice || clearNotice || (modelLoading || !activeStatus
? t('agents.modify.checking')
: activeStatus.modificationState === 'invalid' ? t('agents.modify.invalidState')
: formMatchesConfiguration ? '' : t('agents.modify.pending'))}</span>}
{modificationDescription ? <small>{modificationDescription}</small> : null}
</div>
<div className="agent-modification-control">
{selected === 'codex' ? (
<div className="agent-codex-options">
<label
className="agent-oauth-configuration"
title={t('agents.modify.oauthConfiguration')}
>
<span>{t('agents.modify.oauthConfiguration')}</span>
<span className="switch-control">
<input
type="checkbox"
role="switch"
checked={oauthConfiguration}
onChange={(event) => void changeOauthConfiguration(event.currentTarget.checked)}
disabled={busy}
aria-label={t('agents.modify.oauthConfiguration')}
/>
<span className="switch-track" />
</span>
</label>
<button
type="button"
className="secondary-button agent-codex-catalog-button"
onClick={() => setCodexCatalogDialogOpen(true)}
disabled={busy}
>
<SlidersHorizontal size={16} />
{t('agents.catalog.button')}
</button>
</div>
) : null}
<div className={`agent-modification-buttons ${selected === 'codex' ? 'codex' : ''}`}>
<button
type="button"
className="primary-button"
onClick={() => void (configurationAction === 'close'
? closeConfigurationChanges()
: applyConfigurationChanges())}
disabled={
busy
|| (configurationAction === 'close' ? false : !canEnable)
}
>
{busyAction === 'apply' || busyAction === 'close-config'
? <LoaderCircle size={16} className="spin" />
: null}
{configurationAction === 'update'
? t('agents.modify.update')
: configurationAction === 'close'
? t('agents.modify.close')
: t('agents.modify.apply')}
</button>
<div className="agent-save-actions">
<button type="button" className="secondary-button" onClick={() => setHistoryOpen(true)} disabled={busy}>{t('agents.history.button')}</button>
<button
type="button"
className="secondary-button"
@@ -2115,19 +2097,13 @@ export function AgentsPage({ embedded = false, onConfigurationApplied }: AgentsP
{t('agents.modify.clear')}
</button>
) : null}
</div>
{configurationError ? (
<span className="agent-inline-message error" role="alert" aria-live="polite">
{configurationError}
</span>
) : null}
{clearNotice ? (
<span className="agent-inline-message" role="status" aria-live="polite">
{clearNotice}
</span>
) : null}
<button type="button" className="primary-button" onClick={() => void applyConfigurationChanges()} disabled={busy || !canEnable || configurationWriteBlocked}>
{busyAction === 'apply' ? <LoaderCircle size={16} className="spin" /> : null}
{configurationActionLabel}
</button>
</div>
</section>
</div>
)}
<div className="agent-config-footer">
@@ -2242,6 +2218,17 @@ export function AgentsPage({ embedded = false, onConfigurationApplied }: AgentsP
</section>
</div>
{historyOpen ? <AgentConfigHistoryDialog client={selected} onClose={() => setHistoryOpen(false)} onRestored={async () => {
if (selected === 'claude-code' || selected === 'claude-desktop') claudeModelMappingsDirtyRef.current[selected] = false;
setOauthConfigurationDraft(null);
const refreshed = await invoke<AgentConfigStatus[]>('refresh_agent_config_statuses');
setStatuses(refreshed);
const current = refreshed.find((status) => status.id === selected)?.currentModel;
if (current) setModelByClient((values) => { const next = { ...values, [selected]: current }; writeAgentModelSelections(next); return next; });
setConfigurationNotice(t('agents.history.restored'));
}} /> : null}
{configurationNotice && (embedded || isPiClient) ? <p className="agent-inline-message" role="status">{configurationNotice}</p> : null}
{codexCatalogDialogOpen ? (
<CodexModelCatalogDialog
onClose={() => setCodexCatalogDialogOpen(false)}
+41
View File
@@ -13700,3 +13700,44 @@ body.table-col-resizing * {
flex: 1;
}
}
/* Configuration form and explicit write actions. */
.agent-auth-method { display: grid; gap: 6px; flex: 1; min-width: 0; font-size: var(--font-size-meta); }
.agent-auth-method select { width: 100%; min-height: 38px; padding: 8px 10px; border: 1px solid var(--theme-e3e1db); border-radius: 8px; color: var(--theme-2d2a26); background: var(--theme-fffdf8); }
.agent-model-section .agent-codex-options { margin-top: 16px; align-items: end; }
.agent-model-section .agent-codex-catalog-button { min-height: 38px; height: 38px; padding: 8px 10px; }
.agent-write-state { margin: 0; font-size: var(--font-size-meta); color: var(--theme-7a746c); }
.agent-connection-state { margin: 0; font-size: var(--font-size-meta); font-weight: 600; color: var(--theme-7a746c); }
.agent-connection-state.connected { color: var(--theme-4f825c); }
.agent-signin-hint { grid-column: 1 / -1; margin: 0; color: var(--theme-7a746c); font-size: var(--font-size-meta); line-height: 1.6; }
/* Agent settings: one form and one explicit save action. */
.agent-settings-toolbar { grid-column: 1 / -1; display: flex; align-items: center; justify-content: space-between; gap: 16px; padding-bottom: 16px; border-bottom: 1px solid var(--theme-e3e1db); }
.agent-settings-title { display: grid; gap: 4px; min-width: 0; }
.agent-settings-title > strong { font-size: var(--font-size-section); color: var(--theme-2d2a26); }
.agent-settings-tools { display: flex; align-items: flex-start; gap: 8px; flex-wrap: wrap; }
.agent-model-section { grid-column: 1 / -1; }
.agent-save-bar { grid-column: 1 / -1; display: flex; align-items: center; justify-content: space-between; gap: 20px; padding: 16px 0 0; border-top: 1px solid var(--theme-e3e1db); }
.agent-save-feedback { display: grid; gap: 6px; min-width: 0; }
.agent-save-feedback small { color: var(--theme-7a746c); font-size: var(--font-size-meta); }
.agent-save-actions { display: flex; flex-wrap: wrap; justify-content: flex-end; gap: 8px; margin-left: auto; min-width: 0; }
.agent-save-actions button { min-width: 128px; min-height: 40px; }
.agent-signin-label { display: flex; align-items: center; gap: 8px; }
.agent-signin-help-toggle { display: inline-flex; align-items: center; gap: 4px; min-height: 30px; padding: 3px 6px; border: 0; border-radius: 4px; background: transparent; color: var(--theme-7a746c); font: inherit; cursor: pointer; }
.agent-signin-help-toggle:hover { color: var(--theme-2d2a26); background: var(--theme-fffdf8); }
.agent-signin-help-toggle[aria-expanded="true"] svg { transform: rotate(180deg); }
.agent-signin-explanation { grid-column: 1 / -1; padding: 12px 0 0; border-top: 1px solid var(--theme-e3e1db); font-size: var(--font-size-meta); color: var(--theme-7a746c); overflow-wrap: anywhere; }
.agent-signin-explanation[hidden] { display: none; }
.agent-signin-explanation dl { display: grid; gap: 10px; margin: 0; }
.agent-signin-explanation dl > div { display: grid; grid-template-columns: minmax(110px, 150px) minmax(0, 1fr); gap: 12px; }
.agent-signin-explanation dt { color: var(--theme-2d2a26); font-weight: 600; }
.agent-signin-explanation dd { margin: 0; }
.agent-signin-explanation p { margin: 12px 0 0; }
@media (max-width: 540px) { .agent-signin-explanation dl > div { grid-template-columns: 1fr; gap: 3px; } }
@media (max-width: 760px) {
.agent-settings-toolbar { align-items: flex-start; flex-wrap: wrap; }
.agent-save-bar { flex-wrap: wrap; gap: 12px; }
.agent-save-actions { margin-left: auto; }
}
+75
View File
@@ -0,0 +1,75 @@
// Run Vite on port 1421, then: node tests/agent-history-ui.cjs
// Set PLAYWRIGHT_MODULE to an installed Playwright module path when needed.
const {chromium}=require(process.env.PLAYWRIGHT_MODULE || 'playwright');
const assert=require('node:assert/strict');
(async()=>{const browser=await chromium.launch({channel:'msedge',headless:true}); const page=await browser.newPage({viewport:{width:1280,height:900}});
page.setDefaultTimeout(10000); const errors=[];page.on('pageerror',e=>errors.push(String(e)));
await page.goto('http://localhost:1421/tests/fixtures/agent-history.html?fresh');
await page.getByRole('button',{name:'一键接入',exact:true}).waitFor();
await page.waitForFunction(()=>!document.querySelector('.agent-save-actions .primary-button')?.disabled);
await page.locator('.agent-settings-title strong').filter({hasText:'Codex'}).waitFor();
await page.getByRole('button',{name:'一键接入',exact:true}).click();
await page.getByText('配置已更新,历史备份已保存。',{exact:true}).waitFor();
await page.getByRole('button',{name:'更新配置',exact:true}).click();
await page.getByText('配置已是最新,无需更新。',{exact:true}).waitFor();
assert.equal(await page.getByRole('button',{name:'关闭修改',exact:true}).count(),0);
assert.equal(await page.locator('textarea').count(),0);
await page.locator('.agent-save-feedback').waitFor({state:'attached'});
assert.equal(await page.getByText('已保存',{exact:true}).count(),0);
const help = page.getByRole('button',{name:'如何选择',exact:true});
await help.click();
assert.ok(await page.locator('#agent-signin-hint').isVisible());
assert.equal(await page.locator('#agent-signin-hint dt').count(),2);
const positions = await page.locator('#agent-signin-hint').evaluate(el => ({top:el.getBoundingClientRect().top,bottom:document.getElementById('agent-connection-method').getBoundingClientRect().bottom}));
assert.ok(positions.top >= positions.bottom);
await page.getByRole('button',{name:'收起说明',exact:true}).click();
assert.ok(await page.locator('#agent-signin-hint').isHidden());
await page.getByLabel('接入方式',{exact:true}).selectOption('oauth');
await page.getByText('未保存',{exact:true}).waitFor();
await page.getByLabel('接入方式',{exact:true}).selectOption('apikey');
await page.locator('.agent-save-feedback').waitFor({state:'attached'});
assert.equal(await page.getByText('已保存',{exact:true}).count(),0);
assert.equal(await page.getByRole('button',{name:'重建配置',exact:true}).isVisible(),true);
await page.getByRole('button',{name:'重建配置',exact:true}).click();
await page.getByRole('button',{name:'确认重建配置',exact:true}).waitFor();
await page.getByRole('button',{name:'取消',exact:true}).click();
assert.ok(await page.getByRole('button',{name:'清空配置',exact:true}).isVisible());
await page.screenshot({path:'misc/agent-history-main.png',fullPage:true});
await page.getByRole('button',{name:'配置历史',exact:true}).click();
await page.locator('.agent-history-columns nav button').first().click();
await page.locator('.agent-history-changes table').waitFor();
await page.screenshot({path:'misc/agent-history-dialog.png',fullPage:true});
const before=await page.evaluate(()=>window.fixtureCalls.filter(c=>c.cmd==='update_agent_config').length);
await page.locator('.agent-history-modal footer .primary-button').click();
await page.waitForFunction(()=>!document.querySelector('.agent-history-modal'));
const after=await page.evaluate(()=>window.fixtureCalls.filter(c=>c.cmd==='update_agent_config').length);
assert.equal(before,after);
assert.ok(await page.getByRole('button',{name:'更新配置',exact:true}).isEnabled());
await page.goto('http://localhost:1421/tests/fixtures/agent-history.html?client=claude-code');
await page.getByText('Claude Opus 5',{exact:true}).first().waitFor();
assert.equal(await page.locator('textarea').count(),0);
await page.screenshot({path:'misc/agent-history-claude.png',fullPage:true});
await page.goto('http://localhost:1421/tests/fixtures/agent-history.html?embedded&fresh');
await page.getByRole('button',{name:'一键接入',exact:true}).waitFor();
await page.getByRole('button',{name:'一键接入',exact:true}).click();
await page.getByText('配置已更新,历史备份已保存。',{exact:true}).waitFor();
assert.ok(await page.getByRole('button',{name:'更新配置',exact:true}).isEnabled());
await page.screenshot({path:'misc/agent-history-onboarding.png',fullPage:true});
await page.setViewportSize({width:540,height:820});
await page.getByRole('button',{name:'配置历史',exact:true}).click();
await page.locator('.agent-history-columns nav button').first().click();
await page.locator('.agent-history-changes table').waitFor();
await page.screenshot({path:'misc/agent-history-narrow.png',fullPage:true});
await page.goto('http://localhost:1421/tests/fixtures/agent-history.html?state=needs-update');
await page.getByRole('button',{name:'修复接入',exact:true}).waitFor();
assert.equal(await page.getByRole('button',{name:'一键接入',exact:true}).count(),0);
await page.getByText('当前接入:CPA 配置需要修复',{exact:true}).waitFor();
await page.goto('http://localhost:1421/tests/fixtures/agent-history.html?state=invalid');
await page.getByRole('button',{name:'更新配置',exact:true}).waitFor();
assert.ok(await page.getByRole('button',{name:'更新配置',exact:true}).isDisabled());
assert.equal(await page.getByRole('button',{name:'一键接入',exact:true}).count(),0);
assert.ok(await page.getByRole('button',{name:'重建配置',exact:true}).isEnabled());
assert.deepEqual(errors,[]);
console.log('PASS: first/repeated/no-op update, fixed button, original Claude form, history preview/restore, read-only refresh, onboarding, narrow layout');
await browser.close();})().catch(e=>{console.error(e);process.exit(1)});
+1
View File
@@ -0,0 +1 @@
<html><body><div id="root"></div><script type="module" src="/tests/fixtures/agent-history.tsx"></script></body></html>
+29
View File
@@ -0,0 +1,29 @@
import React from 'react';
import { createRoot } from 'react-dom/client';
import { mockIPC } from '@tauri-apps/api/mocks';
import { AgentsPage } from '../../src/pages/AgentsPage';
import { I18nProvider } from '../../src/i18n';
import '../../src/styles.css';
const params = new URLSearchParams(location.search);
localStorage.setItem('easy-cli-proxy-api.locale','zh-CN');
localStorage.setItem('cpa-gui.agent-selected-client.v1', params.get('client') || 'codex');
const ids = ['claude-code','claude-desktop','codex','opencode','openclaw','hermes','deepseek-harness','zcode','kimi-code','grok-build','pi'];
let count=0; let currentModel=params.has('fresh')?null:'gpt-one';
const calls:any[]=[];(window as any).fixtureCalls=calls;
mockIPC(async (cmd,args:any) => {
calls.push({cmd,args});
if(cmd==='plugin:event|listen') return 1;
if(cmd==='plugin:event|unlisten'||cmd==='set_app_locale') return null;
if(cmd==='get_agent_config_statuses'||cmd==='refresh_agent_config_statuses') return ids.map(id=>({id,name:id,supportedPlatform:true,installed:true,pluginInstalled:true,launchTargets:[{id:'cli',label:'CLI',detail:'test'}],version:'1.0',cliVersion:'1.0',appVersion:null,pluginVersion:'1.0',configValid:params.get('state')!=='invalid',connectionState:params.get('state') || (currentModel?'configured':'not-configured'),configured:!!currentModel,configurationSynchronized:!!currentModel,currentModel,oauthConfiguration:false,modificationEnabled:!!currentModel,modificationState:currentModel?'applied':'unconfigured',backupAvailable:false,appliedModel:currentModel,claudeCodeModelMappings:null,claudeDesktopModelMappings:null,warnings:[],error:null}));
if(cmd==='get_agent_models') return [{name:'gpt-one'},{name:'gpt-two'}];
if(cmd==='get_deepseek_harness_process_status') return {running:false,pid:null,mode:null};
if(cmd==='update_agent_config') {count++;currentModel=args.model;return {outcome:count>1?'unchanged':'updated',historyVersion:'2',model:currentModel,enabled:true,changedFiles:[],conflictFiles:[]};}
if(cmd==='list_agent_config_history') return {versions:[{id:'1',createdAt:'2026-09-09T12:00:00Z',source:'before-update',model:'gpt-two',fileCount:3}],warnings:[]};
if(cmd==='preview_agent_config_history') return {revision:'rev1',differences:[{file:'C:/test/.codex/config.toml',field:'model',before:'gpt-one',after:'gpt-two'},{file:'C:/test/.codex/auth.json',field:'OPENAI_API_KEY',before:'••••••',after:'••••••'}]};
if(cmd==='restore_agent_config_history') {currentModel='gpt-two';return {outcome:'updated'};}
if(cmd==='check_pi_provider_update') return {installedVersion:'1.0',latestVersion:'1.0',updateAvailable:false};
if(cmd==='get_codex_model_catalog_editor') return {models:[],hiddenModels:[],customizations:{}};
if(cmd==='check_codex_oauth_login')return null;
throw new Error('Unhandled fixture command: '+cmd);
});
createRoot(document.getElementById('root')!).render(<I18nProvider><AgentsPage embedded={params.has('embedded')}/></I18nProvider>);