feat(agents): add WorkBuddy configuration support

- Support installation detection, configuration, backup, restore, launch, and restart
- Handle both models.json formats while preserving custom settings
- Simplify model names and retain existing CPA models when switching
- Fix model visibility restoration when disabling integration
- Add regression tests, translations, and documentation
This commit is contained in:
lzt404 committed 2026-09-21 21:58:01 +08:00
1 parent 7b72d97270
commit 3545941414
24 files changed
+1121 -23

No files matched your search

+1
View File
@@ -99,6 +99,7 @@ OpenAI、Claude、Gemini、およびその他の互換形式の間で変換で
- Hermes Agent
- Pi(CLIProxyAPI provider 拡張機能)
- ZCode
- WorkBuddy / WorkBuddy AI
- Kimi Code
- Grok Build
+1
View File
@@ -117,6 +117,7 @@ proxy. Supported clients include:
- Hermes Agent
- Pi (with the CLIProxyAPI provider extension)
- ZCode
- WorkBuddy / WorkBuddy AI
- Kimi Code
- Grok Build
+5
View File
@@ -108,12 +108,17 @@ API 接入页面按照协议或 Provider 管理上游 API 凭证和服务地址
- Hermes Agent
- Pi(通过 CLIProxyAPI provider 插件)
- ZCode
- WorkBuddy / WorkBuddy AI
- Kimi Code
- Grok Build
对于受支持的客户端,软件可以同步可用模型目录、选择默认模型、在应用托管配置前备份原始配置,
以及恢复之前的配置。
WorkBuddy 接入沿用模型选择、更新配置、关闭配置修改、手动备份/恢复和桌面启动/重启流程。
支持 WorkBuddy 与 WorkBuddy AI 的 `models.json` 对象及数组格式,并保留已有自定义模型。
应用后需在 WorkBuddy 内选择 `CPA:模型名`;已有会话不会自动切换模型,未显示时可重启 App。
## 其他功能
- 管理内核配置、API Key、远程管理凭证和路由策略。
+2
View File
@@ -10,6 +10,7 @@ mod native_oauth;
mod state;
mod templates;
mod transactions;
mod workbuddy;
#[cfg(target_os = "windows")]
mod windows_probe;
pub(crate) use backups::*;
@@ -22,5 +23,6 @@ pub(crate) use native_oauth::*;
pub(crate) use state::*;
pub(crate) use templates::*;
pub(crate) use transactions::*;
pub(crate) use workbuddy::*;
#[cfg(target_os = "windows")]
pub(crate) use windows_probe::*;
+17
View File
@@ -84,6 +84,7 @@ fn clients() -> Vec<AgentClient> {
"hermes",
"deepseek-harness",
"zcode",
"workbuddy",
"kimi-code",
"grok-build",
]
@@ -271,6 +272,21 @@ fn corrupt_raw_and_non_utf8_backups_are_saved_but_not_restorable() {
}
}
#[test]
fn workbuddy_native_array_backup_restores_exact_bytes_after_template() {
let home = Home::new();
let client = AgentClient::WorkBuddy;
let paths = config_paths(client.id(), &home.0).unwrap();
let original = b"[\n {\"id\":\"user-model\",\"apiKey\":\"private-key\"}\n]\n";
save(&paths[0], original);
let backup = create_backup(client.id(), &home.0).unwrap();
assert!(backup.restorable);
apply(&home.0, client, "gpt-one").unwrap();
template(&home.0, client).unwrap();
test_restore_backup(client, &home.0, &backup.id);
assert_eq!(fs::read(&paths[0]).unwrap(), original);
}
#[test]
fn restore_validation_uses_the_clients_actual_json_format() {
for (client, restorable) in [
@@ -398,6 +414,7 @@ fn clear_integration_needs_no_backup_and_removes_state_for_each_client() {
AgentClient::Hermes,
AgentClient::DeepSeekHarness,
AgentClient::ZCode,
AgentClient::WorkBuddy,
AgentClient::KimiCode,
AgentClient::GrokBuild,
]
+2 -1
View File
@@ -29,6 +29,7 @@ pub(crate) fn inspect_agent_config_statuses(
AgentStatusDetectionTarget::Client(AgentClient::Hermes),
AgentStatusDetectionTarget::Client(AgentClient::DeepSeekHarness),
AgentStatusDetectionTarget::Client(AgentClient::ZCode),
AgentStatusDetectionTarget::Client(AgentClient::WorkBuddy),
AgentStatusDetectionTarget::Client(AgentClient::KimiCode),
AgentStatusDetectionTarget::Client(AgentClient::GrokBuild),
AgentStatusDetectionTarget::PiProvider,
@@ -956,7 +957,7 @@ pub(crate) fn start_codex_model_catalog_sync(app: tauri::AppHandle) {
pub(crate) fn agent_uses_cpa_runtime_context_windows(client: AgentClient) -> bool {
matches!(
client,
AgentClient::ZCode | AgentClient::KimiCode | AgentClient::GrokBuild
AgentClient::ZCode | AgentClient::WorkBuddy | AgentClient::KimiCode | AgentClient::GrokBuild
)
}
+9
View File
@@ -187,6 +187,13 @@ pub(crate) fn build_agent_updates_with_oauth(
},
])
}
AgentClient::WorkBuddy => {
let before = read_optional_text(&paths[0])?;
Ok(vec![AgentFileUpdate {
path: paths[0].clone(),
after: build_workbuddy_agent_config(before.as_deref(), &openai_base, api_key, model, models)?,
}])
}
AgentClient::ZCode => {
let app_before = read_optional_text(&paths[0])?;
let cli_before = read_optional_text(&paths[1])?;
@@ -1621,6 +1628,7 @@ pub(crate) fn prepare_agent_managed_removal(
AgentClient::Hermes => prepare_hermes_managed_removal(paths),
AgentClient::DeepSeekHarness => prepare_deepseek_harness_managed_removal(paths),
AgentClient::ZCode => prepare_zcode_managed_removal(paths),
AgentClient::WorkBuddy => prepare_workbuddy_managed_removal(paths),
AgentClient::KimiCode => prepare_kimi_code_managed_removal(paths),
AgentClient::GrokBuild => prepare_grok_build_managed_removal(paths),
}
@@ -2658,6 +2666,7 @@ pub(crate) fn build_agent_session_restored_bytes_with_preference(
}
}
AgentClient::ZCode => build_restored_zcode_config(current, original)?,
AgentClient::WorkBuddy => build_restored_workbuddy_config(current, original)?,
AgentClient::KimiCode => build_restored_kimi_code_config(current, original)?,
AgentClient::GrokBuild => build_restored_grok_build_config(current, original)?,
};
+14 -6
View File
@@ -4,14 +4,14 @@ const AGENT_VERSION_PROBE_TIMEOUT: Duration = Duration::from_secs(5);
const AGENT_VERSION_PROBE_POLL_INTERVAL: Duration = Duration::from_millis(20);
#[cfg(not(test))]
fn agent_configuration_environment(name: &str) -> Option<PathBuf> {
pub(crate) fn agent_configuration_environment(name: &str) -> Option<PathBuf> {
env::var_os(name)
.map(PathBuf::from)
.filter(|path| !path.as_os_str().is_empty())
}
#[cfg(test)]
fn agent_configuration_environment(_: &str) -> Option<PathBuf> {
pub(crate) fn agent_configuration_environment(_: &str) -> Option<PathBuf> {
None
}
@@ -47,6 +47,7 @@ pub(crate) fn agent_config_paths(client: AgentClient, home: &Path) -> Vec<PathBu
directory.join(DEEPSEEK_HARNESS_CREDENTIALS_FILE),
]
}
AgentClient::WorkBuddy => vec![workbuddy_home(home).join("models.json")],
AgentClient::ZCode => vec![
home.join(".zcode/v2").join(ZCODE_CONFIG_FILE),
home.join(".zcode/cli").join(ZCODE_CONFIG_FILE),
@@ -980,6 +981,7 @@ pub(crate) fn inspect_agent_config(
.and_then(read_opencode_desktop_version),
AgentClient::DeepSeekHarness => read_deepseek_harness_profile_version(home),
AgentClient::ZCode => executable.as_deref().and_then(read_zcode_app_version),
AgentClient::WorkBuddy => executable.as_deref().and_then(read_workbuddy_app_version),
_ => None,
};
let version = cli_version.clone().or_else(|| app_version.clone());
@@ -1065,13 +1067,13 @@ pub(crate) fn agent_installation_detected(
version.is_some()
|| (matches!(
client,
AgentClient::ClaudeDesktop | AgentClient::OpenCode | AgentClient::ZCode
AgentClient::ClaudeDesktop | AgentClient::OpenCode | AgentClient::ZCode | AgentClient::WorkBuddy
) && executable_found)
|| app_installed
}
pub(crate) fn should_probe_primary_agent_executable_version(client: AgentClient) -> bool {
!matches!(client, AgentClient::ClaudeDesktop | AgentClient::ZCode)
!matches!(client, AgentClient::ClaudeDesktop | AgentClient::ZCode | AgentClient::WorkBuddy)
}
pub(crate) fn agent_launch_targets(
@@ -1125,11 +1127,11 @@ pub(crate) fn agent_launch_targets(
});
}
}
AgentClient::ZCode => {
AgentClient::ZCode | AgentClient::WorkBuddy => {
if let Some(executable) = executable {
targets.push(AgentLaunchTarget {
id: "app".to_string(),
label: "ZCode".to_string(),
label: client.name().to_string(),
detail: path_to_string(executable),
});
}
@@ -1262,6 +1264,8 @@ pub(crate) fn inspect_agent_managed_config(
false,
))
}
AgentClient::WorkBuddy => inspect_workbuddy_agent_config(&paths[0], port, api_key)
.map(|(configured, model)| (configured, model, false)),
AgentClient::KimiCode => inspect_kimi_code_agent_config(&paths[0], port, api_key)
.map(|(configured, model)| (configured, model, false)),
AgentClient::GrokBuild => inspect_grok_build_agent_config(&paths[0], port, api_key)
@@ -1468,6 +1472,7 @@ pub(crate) fn agent_has_managed_marker(
Ok(provider_exists && model_selected)
}
AgentClient::DeepSeekHarness => deepseek_harness_has_managed_marker(paths),
AgentClient::WorkBuddy => workbuddy_has_managed_marker(&paths[0]),
AgentClient::ZCode => {
let prefix = format!("{MANAGED_AGENT_PROVIDER_ID}/");
for path in paths {
@@ -2390,6 +2395,9 @@ pub(crate) fn find_agent_executable(client: AgentClient, home: &Path) -> Option<
if client == AgentClient::ZCode {
return find_zcode_desktop_executable(home);
}
if client == AgentClient::WorkBuddy {
return find_workbuddy_desktop_executable(home);
}
if client == AgentClient::KimiCode {
return find_kimi_code_executable(home);
}
+10 -1
View File
@@ -181,6 +181,11 @@ pub(crate) fn launch_agent(
let requested_target = requested_target.unwrap_or(default_target);
match (client, requested_target) {
(AgentClient::ClaudeDesktop, "app") => launch_claude_desktop(&home),
(AgentClient::WorkBuddy, "app") => {
let executable = find_workbuddy_desktop_executable(&home)
.ok_or_else(|| "未找到 WorkBuddy 应用程序".to_string())?;
launch_desktop_agent(&executable, client.name())
}
(AgentClient::ZCode, "app") => {
let executable = find_zcode_desktop_executable(&home)
.ok_or_else(|| "未找到 ZCode 应用程序".to_string())?;
@@ -188,7 +193,7 @@ pub(crate) fn launch_agent(
}
(AgentClient::Codex, "app") => launch_codex_desktop(&home),
(AgentClient::OpenCode, "app") => launch_opencode_desktop(&home),
(AgentClient::ClaudeDesktop | AgentClient::ZCode, "cli") => {
(AgentClient::ClaudeDesktop | AgentClient::ZCode | AgentClient::WorkBuddy, "cli") => {
Err(format!("{} 不支持 CLI 启动方式", client.name()))
}
(_, "cli") => {
@@ -695,6 +700,7 @@ pub(crate) async fn restart_agent_app(app: tauri::AppHandle, client: String) ->
| AgentClient::OpenCode
| AgentClient::ClaudeDesktop
| AgentClient::ZCode
| AgentClient::WorkBuddy
) {
return Err(format!("{} 不支持桌面应用重启", client.name()));
}
@@ -738,6 +744,9 @@ fn find_desktop_restart_target(
AgentClient::ZCode => {
find_zcode_desktop_executable(home).map(DesktopAppTarget::Application)
}
AgentClient::WorkBuddy => {
find_workbuddy_desktop_executable(home).map(DesktopAppTarget::Application)
}
AgentClient::ClaudeDesktop => {
let executable =
find_claude_desktop_executable(home).map(DesktopAppTarget::Application);
+10 -1
View File
@@ -727,6 +727,9 @@ pub(crate) fn fresh_agent_contents_with_oauth(
build_deepseek_harness_settings(None, &openai_base, model, models)?,
build_deepseek_harness_credentials(None, api_key)?,
]),
AgentClient::WorkBuddy => Ok(vec![build_workbuddy_agent_config(
None, &openai_base, api_key, model, models,
)?]),
AgentClient::ZCode => Ok(vec![
build_zcode_agent_config(None, &root_base, api_key, model, models)?,
build_zcode_cli_agent_config(None, &root_base, api_key, model, models)?,
@@ -1458,7 +1461,13 @@ pub(crate) fn apply_agent_configuration_with_oauth(
validate_config_images(&before)?;
let mappings = options.claude_desktop_model_mappings;
let updates = build_agent_updates_with_oauth(client, home, port, api_key, model, options)
.map_err(|_| "配置构建失败,请检查配置结构或使用手动备份恢复、基础配置模板修复".to_string())?;
.map_err(|error| {
if client == AgentClient::WorkBuddy {
error
} else {
"配置构建失败,请检查配置结构或使用手动备份恢复、基础配置模板修复".to_string()
}
})?;
config_updates(client.id(), home, &before, &updates, "update", Some(model.to_string()), mappings)
}
+27 -2
View File
@@ -113,6 +113,7 @@ fn merge_user_value(
}
fn merge_user_image(
client: &str,
path: &Path,
baseline: Option<&[u8]>,
applied: Option<&[u8]>,
@@ -139,17 +140,23 @@ fn merge_user_image(
.map(|bytes| std::str::from_utf8(bytes).map_err(|_| "原始状态不是 UTF-8 文本"))
.transpose()?,
)?;
let merged = merge_user_value(
let mut merged = merge_user_value(
baseline.map(|_| &baseline_value),
applied.map(|_| &applied_value),
Some(&current_value),
);
if client == "workbuddy" {
if let Some(merged) = &mut merged {
merge_workbuddy_visibility(&baseline_value, &applied_value, &current_value, merged);
}
}
merged
.map(|value| render(path, &value).map(|value| value.into_bytes()))
.transpose()
}
fn promote_user_changes(
client: &str,
paths: &[PathBuf],
baseline: &Images,
applied: &Images,
@@ -164,6 +171,7 @@ fn promote_user_changes(
Ok((
path.clone(),
merge_user_image(
client,
path,
baseline.as_deref(),
applied.as_deref(),
@@ -181,6 +189,7 @@ fn prepare_integration_restore(
state: &AgentIntegrationState,
) -> Result<Images, String> {
let effective_original = promote_user_changes(
client.id(),
paths,
&state.original,
&state.applied,
@@ -410,6 +419,7 @@ fn commit_config_transaction(
let parsed_client = AgentClient::parse(client)?;
let next = if let Some(state) = current_state {
let original = promote_user_changes(
client,
managed_paths,
&state.original,
&state.applied,
@@ -563,6 +573,17 @@ pub(crate) fn validate_config_images(images: &Images) -> Result<(), String> {
pub(crate) fn validate_client_config_images(client: &str, images: &Images) -> Result<(), String> {
validate_config_images(images)?;
if client == "workbuddy" {
for (_, bytes) in images {
parse_workbuddy_config(text(bytes.as_deref())?)?;
}
return Ok(());
}
for (path, bytes) in images {
if !parse(path, text(bytes.as_deref())?)?.is_object() {
return Err("配置根节点必须是对象".into());
}
}
if matches!(client, "opencode" | "openclaw") {
return Ok(());
}
@@ -811,6 +832,9 @@ fn validate_unmanaged_preserved(
before: &Value,
after: &Value,
) -> Result<(), String> {
if client == "workbuddy" {
return validate_workbuddy_unmanaged_preserved(before, after);
}
let project = |value: &Value| -> Result<Value, String> {
let mut value = value.clone();
if client == "claude-desktop" && paths.get(3).is_some_and(|p| p == path) {
@@ -901,7 +925,8 @@ pub(crate) fn parse(path: &Path, content: Option<&str>) -> Result<Value, String>
path_to_string(path)
)
})?;
if !value.is_object() {
// WorkBuddy also saves models.json as a top-level array.
if !value.is_object() && !(path.file_name().is_some_and(|n| n == "models.json") && value.is_array()) {
return Err(format!("{} 配置根节点必须是对象", path_to_string(path)));
}
Ok(value)
+27 -5
View File
@@ -235,11 +235,15 @@ pub(crate) fn windows_app_path_executable(file_name: &str) -> Option<PathBuf> {
}
pub(crate) fn collect_windows_zcode_registrations() -> Vec<WindowsZcodeRegistration> {
collect_windows_desktop_registrations("ZCode.exe", windows_display_name_matches_zcode)
}
fn collect_windows_desktop_registrations(executable: &str, matches_name: fn(&str) -> bool) -> Vec<WindowsZcodeRegistration> {
let mut registrations = Vec::new();
for root in current_version_roots() {
if let Some(path) = RegistryKey::open(
root.hive,
&join_registry_path(root.prefix, r"App Paths\ZCode.exe"),
&join_registry_path(root.prefix, &format!(r"App Paths\{executable}")),
)
.and_then(|key| key.string(""))
{
@@ -263,7 +267,7 @@ pub(crate) fn collect_windows_zcode_registrations() -> Vec<WindowsZcodeRegistrat
let Some(display_name) = entry.string("DisplayName") else {
continue;
};
if !windows_display_name_matches_zcode(&display_name) {
if !matches_name(&display_name) {
continue;
}
if let Some(value) = entry.string("InstallLocation") {
@@ -297,6 +301,10 @@ pub(crate) fn windows_display_name_matches_zcode(name: &str) -> bool {
}
pub(crate) fn parse_windows_zcode_registration(kind: &str, value: &str) -> Option<PathBuf> {
parse_windows_desktop_registration(kind, value, "ZCode.exe")
}
pub(crate) fn parse_windows_desktop_registration(kind: &str, value: &str, executable_name: &str) -> Option<PathBuf> {
let value = value.trim();
let value =
if let Some(quoted) = value.strip_prefix('"') {
@@ -325,14 +333,14 @@ pub(crate) fn parse_windows_zcode_registration(kind: &str, value: &str) -> Optio
}
let executable = match kind {
"executable" => path,
"directory" => path.join("ZCode.exe"),
"icon" | "uninstaller" => path.parent()?.join("ZCode.exe"),
"directory" => path.join(executable_name),
"icon" | "uninstaller" => path.parent()?.join(executable_name),
_ => return None,
};
(executable
.file_name()?
.to_str()?
.eq_ignore_ascii_case("ZCode.exe")
.eq_ignore_ascii_case(executable_name)
&& executable.is_file())
.then_some(executable)
}
@@ -345,6 +353,20 @@ pub(crate) fn find_windows_registered_zcode_executable() -> Option<PathBuf> {
})
}
#[cfg(not(test))]
pub(crate) fn find_windows_registered_workbuddy_executable() -> Option<PathBuf> {
for executable in ["WorkBuddyAI.exe", "WorkBuddy.exe"] {
if let Some(path) = collect_windows_desktop_registrations(executable, |name| {
["WorkBuddyAI", "WorkBuddy AI", "WorkBuddy"].iter().any(|prefix| {
name.strip_prefix(prefix).is_some_and(|rest| rest.is_empty() || rest.starts_with([' ', '(']))
})
}).into_iter().find_map(|r| parse_windows_desktop_registration(r.kind, &r.value, executable)) {
return Some(path);
}
}
None
}
pub(crate) fn read_windows_executable_version(path: &Path) -> Option<String> {
let wide = to_wide(&path_to_string(path));
let mut handle = 0;
+473
View File
@@ -0,0 +1,473 @@
use super::*;
use serde_json::{json, Value};
#[cfg(test)]
mod tests;
// WorkBuddy AI 5.5 reads models.json as either LanguageModel[] or { models, availableModels }.
// Its chat selection is account/session-local; publishing a model must not rewrite that state.
pub(crate) fn workbuddy_home(home: &Path) -> PathBuf {
workbuddy_home_from_environment(
home,
agent_configuration_environment("WORKBUDDY_CONFIG_DIR").as_deref(),
agent_configuration_environment("CODEBUDDY_CONFIG_DIR").as_deref(),
find_workbuddy_desktop_executable(home).as_deref(),
)
}
pub(crate) fn workbuddy_home_from_environment(
home: &Path,
workbuddy_dir: Option<&Path>,
codebuddy_dir: Option<&Path>,
executable: Option<&Path>,
) -> PathBuf {
if let Some(directory) = workbuddy_dir.or(codebuddy_dir) {
return directory.to_path_buf();
}
if let Some(executable) = executable {
let resources = workbuddy_resources(executable);
if let Some(folder) = resources
.and_then(|p| fs::read_to_string(p.join("app.asar.unpacked/cli/product.json")).ok())
.and_then(|s| serde_json::from_str::<Value>(&s).ok())
.and_then(|v| {
v.get("dataFolderName")
.and_then(Value::as_str)
.map(str::to_string)
})
.filter(|s| !s.trim().is_empty())
{
return home.join(folder.trim());
}
}
let ai = home.join(".workbuddy-ai");
let legacy = home.join(".workbuddy");
if !ai.exists() && legacy.join("settings.json").is_file() {
legacy
} else {
ai
}
}
fn workbuddy_resources(executable: &Path) -> Option<PathBuf> {
#[cfg(target_os = "macos")]
{
Some(executable.parent()?.parent()?.join("Resources"))
}
#[cfg(not(target_os = "macos"))]
{
Some(executable.parent()?.join("resources"))
}
}
pub(crate) fn find_workbuddy_desktop_executable(home: &Path) -> Option<PathBuf> {
let mut candidates = Vec::new();
#[cfg(target_os = "windows")]
{
let local = agent_configuration_environment("LOCALAPPDATA")
.unwrap_or_else(|| home.join("AppData/Local"));
let mut roots = vec![local.join("Programs"), local];
for variable in ["ProgramFiles", "ProgramFiles(x86)"] {
if let Some(root) = agent_configuration_environment(variable) {
roots.push(root);
}
}
if let Some(root) = agent_configuration_environment("WORKBUDDY_INSTALL_DIR") {
candidates.extend([root.join("WorkBuddyAI.exe"), root.join("WorkBuddy.exe")]);
}
for root in roots {
for (directory, executable) in [
("WorkBuddyAI", "WorkBuddyAI.exe"),
("WorkBuddy", "WorkBuddy.exe"),
("WorkBuddy AI", "WorkBuddyAI.exe"),
] {
candidates.push(root.join(directory).join(executable));
}
}
}
#[cfg(target_os = "macos")]
for root in [PathBuf::from("/Applications"), home.join("Applications")] {
for (app, executable) in [("WorkBuddy AI", "WorkBuddy AI"), ("WorkBuddy", "WorkBuddy")] {
candidates.push(root.join(format!("{app}.app/Contents/MacOS/{executable}")));
}
}
#[cfg(not(any(target_os = "windows", target_os = "macos")))]
{
candidates.extend([
home.join(".local/bin/workbuddy"),
PathBuf::from("/opt/WorkBuddy/workbuddy"),
PathBuf::from("/opt/WorkBuddyAI/workbuddy"),
]);
}
let found = candidates.into_iter().find(|path| path.is_file());
#[cfg(all(target_os = "windows", not(test)))]
let found = found.or_else(find_windows_registered_workbuddy_executable);
found
}
pub(crate) fn read_workbuddy_app_version(executable: &Path) -> Option<String> {
// Use file metadata, never run an Electron application with --version during detection.
read_zcode_app_version(executable)
}
pub(crate) fn parse_workbuddy_config(content: Option<&str>) -> Result<Value, String> {
let value = match content {
Some(content) => serde_json::from_str(content.strip_prefix('\u{feff}').unwrap_or(content))
.map_err(|_| "WorkBuddy models.json 格式无效,请使用手动备份恢复或基础配置模板修复")?,
None => json!({}),
};
validate_workbuddy_config(&value)?;
Ok(value)
}
pub(crate) fn validate_workbuddy_config(value: &Value) -> Result<(), String> {
let entries = if let Some(array) = value.as_array() {
Some(array)
} else if let Some(root) = value.as_object() {
if root.get("availableModels").is_some_and(|v| {
v.as_array()
.is_none_or(|a| a.iter().any(|v| !v.is_string()))
}) {
return Err("WorkBuddy availableModels 必须是字符串数组".into());
}
match root.get("models") {
None => None,
Some(v) => Some(v.as_array().ok_or("WorkBuddy models 必须是数组")?),
}
} else {
return Err("WorkBuddy models.json 根节点必须是对象或数组".into());
};
let mut ids = std::collections::HashSet::new();
for entry in entries.into_iter().flatten() {
let id = entry
.get("id")
.and_then(Value::as_str)
.filter(|id| !id.trim().is_empty())
.ok_or("WorkBuddy 模型缺少有效 id")?;
if !ids.insert(workbuddy_request_model(id)) {
return Err("WorkBuddy 存在重复模型 id,请先修复配置".into());
}
}
Ok(())
}
fn workbuddy_request_model(id: &str) -> &str {
id.strip_prefix("custom-local:").unwrap_or(id)
}
fn workbuddy_models(value: &Value) -> &[Value] {
value
.as_array()
.or_else(|| value.get("models").and_then(Value::as_array))
.map(Vec::as_slice)
.unwrap_or_default()
}
fn workbuddy_managed_model(value: &Value) -> bool {
value.get("vendor").and_then(Value::as_str) == Some(MANAGED_AGENT_PROVIDER_ID)
}
fn set_workbuddy_models(value: &mut Value, models: Vec<Value>) {
if value.is_array() {
*value = Value::Array(models);
} else if models.is_empty() {
value.as_object_mut().unwrap().remove("models");
} else {
value["models"] = Value::Array(models);
}
}
pub(crate) fn build_workbuddy_agent_config(
existing: Option<&str>,
base_url: &str,
api_key: &str,
model: &str,
available_models: &[AgentModelOption],
) -> Result<String, String> {
let mut root = parse_workbuddy_config(existing)?;
let mut entries = workbuddy_models(&root)
.iter()
.filter(|m| !workbuddy_managed_model(m))
.cloned()
.collect::<Vec<_>>();
if entries.iter().any(|m| {
m.get("id")
.and_then(Value::as_str)
.is_some_and(|id| workbuddy_request_model(id) == model)
}) {
return Err(
"WorkBuddy 已有同名自定义模型,请先在 WorkBuddy 中修改该模型 ID,或选择其他 CPA 模型"
.into(),
);
}
let mut entry = workbuddy_models(&root)
.iter()
.find(|m| {
workbuddy_managed_model(m)
&& m.get("id")
.and_then(Value::as_str)
.map(workbuddy_request_model)
== Some(model)
})
.cloned()
.unwrap_or_else(|| json!({}));
entry["id"] = json!(model);
update_workbuddy_model(&mut entry, base_url, api_key, model, available_models);
entries.push(entry);
for previous in workbuddy_models(&root)
.iter()
.filter(|m| workbuddy_managed_model(m))
{
let id = previous["id"].as_str().ok_or("WorkBuddy 模型缺少有效 id")?;
let previous_model = workbuddy_request_model(id);
if previous_model == model {
continue;
}
let mut retained = previous.clone();
update_workbuddy_model(
&mut retained,
base_url,
api_key,
previous_model,
available_models,
);
entries.push(retained);
}
set_workbuddy_models(&mut root, entries);
if let Some(visible) = root
.get_mut("availableModels")
.and_then(Value::as_array_mut)
{
if !visible.is_empty()
&& !visible
.iter()
.any(|v| v.as_str().map(workbuddy_request_model) == Some(model))
{
visible.push(json!(model));
}
}
serde_json::to_string_pretty(&root).map_err(|_| "生成 WorkBuddy 配置失败".into())
}
fn update_workbuddy_model(
entry: &mut Value,
base_url: &str,
api_key: &str,
model: &str,
available_models: &[AgentModelOption],
) {
entry["name"] = json!("CPA");
entry["vendor"] = json!(MANAGED_AGENT_PROVIDER_ID);
entry["url"] = json!(format!(
"{}/chat/completions",
base_url.trim_end_matches('/')
));
entry["apiKey"] = json!(api_key);
entry["supportsToolCall"] = json!(true);
entry["disabled"] = json!(false);
// WorkBuddy prefixes local model IDs itself and removes that prefix before sending requests.
entry["useCustomProtocol"] = json!(false);
if let Some(metadata) = available_models.iter().find(|m| m.name == model) {
if let Some(context) = metadata.context_window {
entry["maxInputTokens"] = json!(context);
}
if let Some(input) = &metadata.input_modalities {
entry["supportsImages"] = json!(input.iter().any(|s| s == "image"));
}
}
}
pub(crate) fn workbuddy_has_managed_marker(path: &Path) -> Result<bool, String> {
let root = parse_workbuddy_config(read_optional_text(path)?.as_deref())?;
Ok(workbuddy_models(&root).iter().any(workbuddy_managed_model))
}
pub(crate) fn inspect_workbuddy_agent_config(
path: &Path,
port: u16,
api_key: &str,
) -> Result<(bool, Option<String>), String> {
let root = parse_workbuddy_config(read_optional_text(path)?.as_deref())?;
let managed = workbuddy_models(&root)
.iter()
.filter(|m| workbuddy_managed_model(m))
.collect::<Vec<_>>();
let Some(entry) = managed.first() else {
return Ok((false, None));
};
let model = entry
.get("id")
.and_then(Value::as_str)
.map(workbuddy_request_model);
let visible = root
.get("availableModels")
.and_then(Value::as_array)
.is_none_or(|a| {
a.is_empty()
|| a.iter()
.any(|v| v.as_str().map(workbuddy_request_model) == model)
});
let expected_url = format!("{}/v1/chat/completions", managed_core_loopback_origin(port));
let configured = visible
&& managed.iter().all(|entry| {
entry.get("url").and_then(Value::as_str) == Some(&expected_url)
&& entry.get("apiKey").and_then(Value::as_str) == Some(api_key)
&& entry.get("disabled").and_then(Value::as_bool) != Some(true)
&& entry.get("supportsToolCall").and_then(Value::as_bool) == Some(true)
});
Ok((configured, model.map(str::to_string)))
}
pub(crate) fn strip_workbuddy_managed(root: &mut Value) {
let ids = workbuddy_models(root)
.iter()
.filter(|m| workbuddy_managed_model(m))
.filter_map(|m| m.get("id").and_then(Value::as_str).map(str::to_string))
.collect::<Vec<_>>();
let entries = workbuddy_models(root)
.iter()
.filter(|m| !workbuddy_managed_model(m))
.cloned()
.collect();
set_workbuddy_models(root, entries);
if let Some(visible) = root
.get_mut("availableModels")
.and_then(Value::as_array_mut)
{
visible.retain(|v| {
!v.as_str().is_some_and(|id| {
ids.iter()
.any(|old| workbuddy_request_model(id) == workbuddy_request_model(old))
})
});
}
}
pub(crate) fn merge_workbuddy_visibility(
baseline: &Value,
applied: &Value,
current: &Value,
merged: &mut Value,
) {
if !merged.is_object() || current.get("availableModels") == applied.get("availableModels") {
return;
}
let Some(now) = current.get("availableModels").and_then(Value::as_array) else {
merged.as_object_mut().unwrap().remove("availableModels");
return;
};
let original_ids = baseline
.get("availableModels")
.and_then(Value::as_array)
.into_iter()
.flatten()
.filter_map(Value::as_str)
.map(workbuddy_request_model)
.collect::<Vec<_>>();
let removed_ids = [applied, current]
.into_iter()
.flat_map(workbuddy_models)
.filter(|m| workbuddy_managed_model(m))
.filter_map(|m| m.get("id").and_then(Value::as_str))
.map(workbuddy_request_model)
.collect::<Vec<_>>();
let surviving_ids = workbuddy_models(current)
.iter()
.filter(|m| !workbuddy_managed_model(m))
.filter_map(|m| m.get("id").and_then(Value::as_str))
.map(workbuddy_request_model)
.collect::<Vec<_>>();
let visible = now
.iter()
.filter(|value| {
let Some(id) = value.as_str().map(workbuddy_request_model) else {
return false;
};
!removed_ids.contains(&id) || original_ids.contains(&id) || surviving_ids.contains(&id)
})
.cloned()
.collect::<Vec<_>>();
merged["availableModels"] = json!(visible);
}
pub(crate) fn validate_workbuddy_unmanaged_preserved(
before: &Value,
after: &Value,
) -> Result<(), String> {
let ids = [before, after]
.into_iter()
.flat_map(workbuddy_models)
.filter(|m| workbuddy_managed_model(m))
.filter_map(|m| m.get("id").and_then(Value::as_str))
.map(workbuddy_request_model)
.collect::<Vec<_>>();
let project = |value: &Value| {
let mut value = value.clone();
strip_workbuddy_managed(&mut value);
if let Some(visible) = value
.get_mut("availableModels")
.and_then(Value::as_array_mut)
{
visible.retain(|v| {
!v.as_str()
.is_some_and(|id| ids.contains(&workbuddy_request_model(id)))
});
}
value
};
if project(before) != project(after) {
return Err("更新意外改变了 WorkBuddy 自定义配置,已拒绝写入".into());
}
Ok(())
}
pub(crate) fn build_restored_workbuddy_config(
current: &str,
original: Option<&str>,
) -> Result<Option<String>, String> {
let mut root = parse_workbuddy_config(Some(current))?;
strip_workbuddy_managed(&mut root);
if let Some(original) = original {
let before = parse_workbuddy_config(Some(original))?;
// Keep an explicitly empty models list and the original visibility filter when unchanged.
if root.is_object() && before.get("models").is_some() && root.get("models").is_none() {
root["models"] = json!([]);
}
if root.is_object() {
if let Some(visible) = before.get("availableModels") {
root["availableModels"] = visible.clone();
} else {
root.as_object_mut().unwrap().remove("availableModels");
}
}
}
if original.is_none()
&& (root.as_object().is_some_and(|r| r.is_empty())
|| root.as_array().is_some_and(|r| r.is_empty()))
{
return Ok(None);
}
serde_json::to_string_pretty(&root)
.map(Some)
.map_err(|_| "恢复 WorkBuddy 配置失败".into())
}
pub(crate) fn prepare_workbuddy_managed_removal(paths: &[PathBuf]) -> Result<Images, String> {
let path = paths.first().ok_or("WorkBuddy 配置路径不可用")?;
let Some(current) = read_optional_text(path)? else {
return Ok(Vec::new());
};
let mut value = parse_workbuddy_config(Some(&current))?;
if !workbuddy_models(&value).iter().any(workbuddy_managed_model) {
return Ok(Vec::new());
}
strip_workbuddy_managed(&mut value);
let bytes = if value.as_object().is_some_and(|v| v.is_empty())
|| value.as_array().is_some_and(|v| v.is_empty())
{
None
} else {
Some(serde_json::to_vec_pretty(&value).map_err(|_| "生成 WorkBuddy 配置失败")?)
};
Ok(vec![(path.clone(), bytes)])
}
+490
View File
@@ -0,0 +1,490 @@
use super::*;
struct Home(PathBuf);
impl Home {
fn new() -> Self {
let path = env::temp_dir().join(format!(
"cpa-workbuddy-test-{}-{}",
std::process::id(),
SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_nanos()
));
fs::create_dir_all(&path).unwrap();
Self(path)
}
fn path(&self) -> PathBuf {
self.0.join(".workbuddy-ai/models.json")
}
fn save(&self, value: &Value) {
fs::create_dir_all(self.path().parent().unwrap()).unwrap();
fs::write(self.path(), serde_json::to_string_pretty(value).unwrap()).unwrap();
}
fn read(&self) -> Value {
parse_workbuddy_config(read_optional_text(&self.path()).unwrap().as_deref()).unwrap()
}
fn apply(&self, model: &str) -> Result<AgentConfigActionResult, String> {
apply_agent_configuration_with_oauth(
AgentClient::WorkBuddy,
&self.0,
8317,
"test-key",
model,
AgentConfigurationOptions {
models: &[],
codex_catalog: None,
oauth_configuration: false,
claude_code_model_mappings: None,
claude_desktop_model_mappings: None,
},
)
}
}
impl Drop for Home {
fn drop(&mut self) {
let _ = fs::remove_dir_all(&self.0);
}
}
#[test]
fn workbuddy_formats_round_trip_through_update_backup_and_disable() {
for original in [
json!([]),
json!({}),
json!({"models": []}),
json!([{"id":"my-model","url":"https://example.test/v1/chat/completions","apiKey":"private"}]),
json!({"models":[{"id":"my-model","extensions":{"keep":true}}],"availableModels":["my-model"],"custom":true}),
json!({"availableModels":["gpt-one","my-model"]}),
] {
let home = Home::new();
home.save(&original);
home.apply("gpt-one").unwrap();
assert_eq!(
inspect_workbuddy_agent_config(&home.path(), 8317, "test-key").unwrap(),
(true, Some("gpt-one".into()))
);
let bytes = fs::read(home.path()).unwrap();
assert_eq!(home.apply("gpt-one").unwrap().outcome, "unchanged");
assert_eq!(fs::read(home.path()).unwrap(), bytes);
assert_eq!(
serde_json::to_value(create_backup("workbuddy", &home.0).unwrap()).unwrap()
["restorable"],
true
);
clear_agent_managed_configuration(AgentClient::WorkBuddy, &home.0, 8317).unwrap();
assert_eq!(home.read(), original);
}
}
#[test]
fn workbuddy_switching_and_disable_keep_external_models_and_settings() {
for array in [false, true] {
let home = Home::new();
home.save(&if array {
json!([])
} else {
json!({"availableModels":["built-in"]})
});
home.apply("gpt-one").unwrap();
let mut edited = home.read();
let mut entries = workbuddy_models(&edited).to_vec();
entries[0]["temperature"] = json!(0.2);
let first_model = entries[0].clone();
entries.push(json!({"id":"user-added","apiKey":"user-key","extra":{"a":[1,2]}}));
set_workbuddy_models(&mut edited, entries);
home.save(&edited);
home.apply("gpt-two").unwrap();
let updated = home.read();
assert_eq!(workbuddy_models(&updated).len(), 3);
assert!(workbuddy_models(&updated).contains(&first_model));
assert_eq!(
inspect_workbuddy_agent_config(&home.path(), 8317, "test-key").unwrap(),
(true, Some("gpt-two".into()))
);
if !array {
assert_eq!(
updated["availableModels"],
json!(["built-in", "gpt-one", "gpt-two"])
);
}
let second_model = workbuddy_models(&updated)
.iter()
.find(|m| m["id"] == "gpt-two")
.unwrap()
.clone();
home.apply("gpt-one").unwrap();
let switched_back = home.read();
assert_eq!(workbuddy_models(&switched_back).len(), 3);
assert!(workbuddy_models(&switched_back).contains(&first_model));
assert!(workbuddy_models(&switched_back).contains(&second_model));
assert_eq!(
inspect_workbuddy_agent_config(&home.path(), 8317, "test-key").unwrap(),
(true, Some("gpt-one".into()))
);
let bytes = fs::read(home.path()).unwrap();
assert_eq!(home.apply("gpt-one").unwrap().outcome, "unchanged");
assert_eq!(fs::read(home.path()).unwrap(), bytes);
clear_agent_managed_configuration(AgentClient::WorkBuddy, &home.0, 8317).unwrap();
assert_eq!(
workbuddy_models(&home.read()),
&[json!({"id":"user-added","apiKey":"user-key","extra":{"a":[1,2]}})]
);
if !array {
assert_eq!(home.read()["availableModels"], json!(["built-in"]));
}
}
}
#[test]
fn workbuddy_status_checks_endpoint_key_visibility_and_tool_support() {
let home = Home::new();
home.apply("gpt-one").unwrap();
assert!(
!inspect_workbuddy_agent_config(&home.path(), 8318, "test-key")
.unwrap()
.0
);
assert!(
!inspect_workbuddy_agent_config(&home.path(), 8317, "wrong-key")
.unwrap()
.0
);
let original = home.read();
for (pointer, value) in [
("/availableModels", json!(["hidden"])),
("/models/0/disabled", json!(true)),
("/models/0/supportsToolCall", json!(false)),
("/models/0/url", json!("http://127.0.0.1:8317/v1")),
] {
let mut changed = original.clone();
if pointer == "/availableModels" {
changed["availableModels"] = value;
} else {
*changed.pointer_mut(pointer).unwrap() = value;
}
home.save(&changed);
assert!(
!inspect_workbuddy_agent_config(&home.path(), 8317, "test-key")
.unwrap()
.0
);
assert!(workbuddy_has_managed_marker(&home.path()).unwrap());
}
let mut prefixed = original;
prefixed["availableModels"] = json!(["custom-local:gpt-one"]);
home.save(&prefixed);
assert!(
inspect_workbuddy_agent_config(&home.path(), 8317, "test-key")
.unwrap()
.0
);
}
#[test]
fn workbuddy_visibility_restores_original_references_and_keeps_user_edits() {
for external_edit in [false, true] {
let home = Home::new();
home.save(&json!({"availableModels":["gpt-one","built-in"]}));
home.apply("gpt-one").unwrap();
home.apply("gpt-two").unwrap();
if external_edit {
let mut current = home.read();
current["availableModels"] = json!(["gpt-two", "user-added"]);
home.save(&current);
}
clear_agent_managed_configuration(AgentClient::WorkBuddy, &home.0, 8317).unwrap();
assert_eq!(
home.read()["availableModels"],
if external_edit {
json!(["user-added"])
} else {
json!(["gpt-one", "built-in"])
}
);
}
}
#[test]
fn workbuddy_new_visibility_filters_remove_managed_references_on_disable() {
for original in [json!({}), json!({"availableModels": []}), json!([])] {
for reapply in [false, true] {
for (visible, expected) in [
(json!(["gpt-one"]), json!([])),
(
json!(["custom-local:gpt-one", "custom-local:gpt-two", "built-in"]),
json!(["built-in"]),
),
] {
let home = Home::new();
home.save(&original);
home.apply("gpt-one").unwrap();
home.apply("gpt-two").unwrap();
let current = home.read();
let mut current = if current.is_array() {
json!({"models": current})
} else {
current
};
current["availableModels"] = visible;
home.save(&current);
if reapply {
home.apply("gpt-two").unwrap();
}
clear_agent_managed_configuration(AgentClient::WorkBuddy, &home.0, 8317).unwrap();
let restored = home.read();
assert!(workbuddy_models(&restored).is_empty());
assert_eq!(restored["availableModels"], expected);
}
}
}
}
#[test]
fn workbuddy_cleared_visibility_does_not_restore_old_references() {
for reapply in [false, true] {
for remove_field in [false, true] {
let home = Home::new();
home.save(&json!({"availableModels": ["gpt-one", "built-in"]}));
home.apply("gpt-one").unwrap();
home.apply("gpt-two").unwrap();
let mut current = home.read();
if remove_field {
current.as_object_mut().unwrap().remove("availableModels");
} else {
current["availableModels"] = json!([]);
}
home.save(&current);
if reapply {
home.apply("gpt-two").unwrap();
}
clear_agent_managed_configuration(AgentClient::WorkBuddy, &home.0, 8317).unwrap();
let restored = home.read();
assert!(workbuddy_models(&restored).is_empty());
if remove_field {
assert!(restored.get("availableModels").is_none());
} else {
assert_eq!(restored["availableModels"], json!([]));
}
}
}
}
#[test]
fn workbuddy_visibility_keeps_models_detached_from_cpa() {
for reapply in [false, true] {
let home = Home::new();
home.apply("gpt-one").unwrap();
let mut current = home.read();
current["models"][0]["vendor"] = json!("user-provider");
current["availableModels"] = json!(["custom-local:gpt-one", "built-in"]);
home.save(&current);
if reapply {
home.apply("gpt-two").unwrap();
}
clear_agent_managed_configuration(AgentClient::WorkBuddy, &home.0, 8317).unwrap();
assert_eq!(home.read(), current);
}
}
#[test]
fn workbuddy_write_guard_rejects_removing_unrelated_visible_models() {
let before = json!({"availableModels":["user-model"]});
let rendered = build_workbuddy_agent_config(
Some(&before.to_string()),
"http://127.0.0.1:8317/v1",
"key",
"gpt-one",
&[],
)
.unwrap();
let mut after = parse_workbuddy_config(Some(&rendered)).unwrap();
validate_workbuddy_unmanaged_preserved(&before, &after).unwrap();
after["availableModels"] = json!(["gpt-one"]);
assert!(validate_workbuddy_unmanaged_preserved(&before, &after).is_err());
}
#[test]
fn workbuddy_rejects_collisions_and_malformed_configs_without_writing() {
for value in [
json!([{"id":"gpt-one","apiKey":"user-key"}]),
json!({"models":[{"id":"custom-local:gpt-one","apiKey":"user-key"}]}),
json!({"models":{}}),
json!({"availableModels":false}),
json!({"models":[{"id":"same"},{"id":"custom-local:same"}]}),
] {
let home = Home::new();
home.save(&value);
let before = fs::read(home.path()).unwrap();
assert!(home.apply("gpt-one").is_err());
assert_eq!(fs::read(home.path()).unwrap(), before);
}
}
#[test]
fn workbuddy_switch_refreshes_retained_credentials_and_metadata() {
let home = Home::new();
home.apply("vision").unwrap();
let mut current = home.read();
current["models"][0]["id"] = json!("custom-local:vision");
current["models"][0]["temperature"] = json!(0.2);
current["availableModels"] = json!(["custom-local:vision"]);
let next = build_workbuddy_agent_config(
Some(&current.to_string()),
"http://127.0.0.1:8318/v1",
"new-key",
"gpt-two",
&[AgentModelOption {
name: "vision".into(),
alias: None,
is_alias: false,
context_window: Some(128_000),
input_modalities: Some(vec!["text".into(), "image".into()]),
harness_metadata: None,
}],
)
.unwrap();
let updated = parse_workbuddy_config(Some(&next)).unwrap();
assert_eq!(
updated["availableModels"],
json!(["custom-local:vision", "gpt-two"])
);
assert_eq!(workbuddy_models(&updated).len(), 2);
let retained = &updated["models"][1];
assert_eq!(retained["id"], "custom-local:vision");
assert_eq!(retained["maxInputTokens"], 128_000);
assert_eq!(retained["supportsImages"], true);
assert_eq!(retained["temperature"], 0.2);
for entry in workbuddy_models(&updated) {
assert_eq!(entry["url"], "http://127.0.0.1:8318/v1/chat/completions");
assert_eq!(entry["apiKey"], "new-key");
}
home.save(&updated);
assert_eq!(
inspect_workbuddy_agent_config(&home.path(), 8318, "new-key").unwrap(),
(true, Some("gpt-two".into()))
);
for (field, value) in [
("apiKey", json!("old-key")),
("url", json!("http://127.0.0.1:8317/v1/chat/completions")),
("disabled", json!(true)),
("supportsToolCall", json!(false)),
] {
let mut stale = updated.clone();
stale["models"][1][field] = value;
home.save(&stale);
assert_eq!(
inspect_workbuddy_agent_config(&home.path(), 8318, "new-key").unwrap(),
(false, Some("gpt-two".into()))
);
}
let switched_back = build_workbuddy_agent_config(
Some(&next),
"http://127.0.0.1:8318/v1",
"new-key",
"vision",
&[],
)
.unwrap();
let switched_back = parse_workbuddy_config(Some(&switched_back)).unwrap();
assert_eq!(workbuddy_models(&switched_back).len(), 2);
assert_eq!(switched_back["models"][0]["id"], "vision");
assert_eq!(switched_back["models"][0]["temperature"], 0.2);
assert_eq!(switched_back["availableModels"], updated["availableModels"]);
home.save(&switched_back);
assert_eq!(
inspect_workbuddy_agent_config(&home.path(), 8318, "new-key").unwrap(),
(true, Some("vision".into()))
);
}
#[test]
fn workbuddy_metadata_and_model_extensions_survive_reapply() {
let config = build_workbuddy_agent_config(
None,
"http://127.0.0.1:8317/v1",
"key",
"vision",
&[AgentModelOption {
name: "vision".into(),
alias: None,
is_alias: false,
context_window: Some(128_000),
input_modalities: Some(vec!["text".into(), "image".into()]),
harness_metadata: None,
}],
)
.unwrap();
let mut value = parse_workbuddy_config(Some(&config)).unwrap();
assert_eq!(value["models"][0]["maxInputTokens"], 128_000);
assert_eq!(value["models"][0]["supportsImages"], true);
assert_eq!(
value["models"][0]["url"],
"http://127.0.0.1:8317/v1/chat/completions"
);
value["models"][0]["temperature"] = json!(0.2);
let next = build_workbuddy_agent_config(
Some(&value.to_string()),
"http://127.0.0.1:8318/v1",
"new",
"vision",
&[],
)
.unwrap();
assert_eq!(
parse_workbuddy_config(Some(&next)).unwrap()["models"][0]["temperature"],
0.2
);
}
#[test]
fn workbuddy_paths_use_environment_then_installed_product_data_folder() {
let home = Home::new();
let executable = home.0.join("install/WorkBuddyAI.exe");
let product = workbuddy_resources(&executable)
.unwrap()
.join("app.asar.unpacked/cli/product.json");
fs::create_dir_all(product.parent().unwrap()).unwrap();
fs::write(product, r#"{"dataFolderName":".workbuddy-enterprise"}"#).unwrap();
let resolve = |wb, cb| workbuddy_home_from_environment(&home.0, wb, cb, Some(&executable));
assert_eq!(resolve(None, None), home.0.join(".workbuddy-enterprise"));
assert_eq!(resolve(Some(&home.0), None), home.0);
assert_eq!(resolve(None, Some(&home.0)), home.0);
assert_eq!(resolve(Some(&home.0), Some(Path::new("unused"))), home.0);
}
#[cfg(target_os = "windows")]
#[test]
fn workbuddy_detects_local_install_without_launching_it() {
let home = Home::new();
let executable = home
.0
.join("AppData/Local/Programs/WorkBuddyAI/WorkBuddyAI.exe");
fs::create_dir_all(executable.parent().unwrap()).unwrap();
fs::write(&executable, "not an executable").unwrap();
assert_eq!(
find_workbuddy_desktop_executable(&home.0),
Some(executable.clone())
);
assert!(agent_installation_detected(
AgentClient::WorkBuddy,
None,
true,
false
));
assert!(!should_probe_primary_agent_executable_version(
AgentClient::WorkBuddy
));
assert_eq!(read_workbuddy_app_version(&executable), None);
let targets = agent_launch_targets(AgentClient::WorkBuddy, Some(&executable), None, false);
assert_eq!(targets.len(), 1);
assert_eq!(targets[0].id, "app");
assert_eq!(
parse_windows_desktop_registration(
"icon",
&format!("\"{}\",0", executable.display()),
"WorkBuddyAI.exe"
),
Some(executable)
);
}
+1
View File
@@ -74,6 +74,7 @@ fn tracked_configuration_paths(app: &tauri::AppHandle) -> Result<Vec<PathBuf>, S
AgentClient::Hermes,
AgentClient::DeepSeekHarness,
AgentClient::ZCode,
AgentClient::WorkBuddy,
AgentClient::KimiCode,
AgentClient::GrokBuild,
] {
+5
View File
@@ -1362,6 +1362,7 @@ enum AgentClient {
Hermes,
DeepSeekHarness,
ZCode,
WorkBuddy,
KimiCode,
GrokBuild,
}
@@ -1384,6 +1385,7 @@ impl AgentClient {
"hermes" => Ok(Self::Hermes),
"deepseek-harness" => Ok(Self::DeepSeekHarness),
"zcode" => Ok(Self::ZCode),
"workbuddy" => Ok(Self::WorkBuddy),
"kimi-code" => Ok(Self::KimiCode),
"grok-build" => Ok(Self::GrokBuild),
_ => Err(format!("不支持的智能体客户端: {value}")),
@@ -1400,6 +1402,7 @@ impl AgentClient {
Self::Hermes => "hermes",
Self::DeepSeekHarness => "deepseek-harness",
Self::ZCode => "zcode",
Self::WorkBuddy => "workbuddy",
Self::KimiCode => "kimi-code",
Self::GrokBuild => "grok-build",
}
@@ -1415,6 +1418,7 @@ impl AgentClient {
Self::Hermes => "Hermes Agent",
Self::DeepSeekHarness => "DeepSeek Harness",
Self::ZCode => "ZCode",
Self::WorkBuddy => "WorkBuddy",
Self::KimiCode => "Kimi Code",
Self::GrokBuild => "Grok Build",
}
@@ -1439,6 +1443,7 @@ impl AgentClient {
Self::Hermes => &["hermes"],
Self::DeepSeekHarness => &["dsh"],
Self::ZCode => &["zcode"],
Self::WorkBuddy => &[],
Self::KimiCode => &["kimi"],
Self::GrokBuild => &["grok"],
}
+4
View File
@@ -13,6 +13,7 @@ fn configuration_paths_ignore_inherited_environment() {
"hermes",
"deepseek-harness",
"zcode",
"workbuddy",
"kimi-code",
"grok-build",
"pi",
@@ -42,6 +43,9 @@ fn configuration_paths_ignore_inherited_environment() {
"KIMI_CODE_HOME",
"GROK_HOME",
"DSH_HOME",
"WORKBUDDY_CONFIG_DIR",
"CODEBUDDY_CONFIG_DIR",
"WORKBUDDY_INSTALL_DIR",
"PI_CODING_AGENT_DIR",
] {
command.env(variable, &outside);
Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 KiB

+2
View File
@@ -937,6 +937,8 @@ export const jaOverrides = {
'agents.description.openclaw': 'CPA モデルプロバイダーと既定モデルを使用',
'agents.description.hermes': 'CPA カスタムプロバイダーを使用',
'agents.description.deepseekHarness': 'Harness のネイティブ DeepSeek 設定と分離された OpenAI 互換ルートで CPA を使用',
'agents.description.workbuddy': 'CPA の OpenAI Chat Completions API で WorkBuddy のカスタムモデルを設定',
'agents.modify.workbuddyHint': '適用後、WorkBuddy で「CPA:モデル名」を選択してください。表示されない場合は App を再起動してください。既存の会話のモデルは自動で切り替わりません。',
'agents.description.zcode': 'CPA の Anthropic Messages API と動的モデルカタログを使用',
'agents.description.kimiCode': 'CPA の OpenAI Chat Completions API と動的モデルカタログを使用',
'agents.description.grokBuild': 'CPA の OpenAI Chat Completions API とカスタムモデルカタログを使用',
+2
View File
@@ -937,6 +937,8 @@ export const en: Record<MessageKey, string> = {
'agents.description.openclaw': 'Uses the CPA model provider and default model',
'agents.description.hermes': 'Uses the CPA custom provider',
'agents.description.deepseekHarness': 'Uses CPA through an isolated, OpenAI-compatible Harness route',
'agents.description.workbuddy': 'Configures a WorkBuddy custom model using the CPA OpenAI Chat Completions API',
'agents.modify.workbuddyHint': 'After applying, select “CPA:model name” in WorkBuddy. Restart the app if it is missing; existing conversations keep their selected model.',
'agents.description.zcode': 'Uses the CPA Anthropic Messages API and dynamic model catalog',
'agents.description.kimiCode': 'Uses the CPA OpenAI Chat Completions API and dynamic model catalog',
'agents.description.grokBuild': 'Uses the CPA OpenAI Chat Completions API and custom model catalog',
+2
View File
@@ -936,6 +936,8 @@ export const zhCN = {
'agents.description.openclaw': '使用 CPA 模型供应商和默认模型',
'agents.description.hermes': '使用 CPA 自定义提供方',
'agents.description.deepseekHarness': '通过通用 OpenAI 兼容路由使用 CPA,并与 Harness 原生 DeepSeek 配置隔离',
'agents.description.workbuddy': '使用 CPA 的 OpenAI Chat Completions 接口配置 WorkBuddy 自定义模型',
'agents.modify.workbuddyHint': '应用后,在 WorkBuddy 的模型选择器中选择「CPA:模型名」。若未显示,请重启 App;已有会话的模型不会自动切换。',
'agents.description.zcode': '使用 CPA 的 Anthropic Messages 接口和动态模型目录',
'agents.description.kimiCode': '使用 CPA 的 OpenAI Chat Completions 接口和动态模型目录',
'agents.description.grokBuild': '使用 CPA 的 OpenAI Chat Completions 接口和自定义模型目录',
+13 -3
View File
@@ -36,6 +36,7 @@ import openclawIcon from '../assets/icons/openclaw.svg';
import opencodeIcon from '../assets/icons/opencode.svg';
import piIcon from '../assets/icons/pi-logo-on-light.svg';
import zcodeIcon from '../assets/icons/zcode.png';
import workbuddyIcon from '../assets/icons/workbuddy.png';
import {
agentModelAlias,
filterAgentModels,
@@ -79,6 +80,7 @@ type AgentClientId =
| 'hermes'
| 'deepseek-harness'
| 'zcode'
| 'workbuddy'
| 'kimi-code'
| 'grok-build'
| 'pi';
@@ -228,7 +230,7 @@ type AgentDefinition = {
name: string;
icon?: string;
Icon?: ComponentType<{ size?: number; 'aria-hidden'?: boolean }>;
descriptionKey: 'agents.description.claudeCode' | 'agents.description.claudeDesktop' | 'agents.description.codex' | 'agents.description.opencode' | 'agents.description.openclaw' | 'agents.description.hermes' | 'agents.description.deepseekHarness' | 'agents.description.zcode' | 'agents.description.kimiCode' | 'agents.description.grokBuild' | 'agents.description.pi';
descriptionKey: 'agents.description.claudeCode' | 'agents.description.claudeDesktop' | 'agents.description.codex' | 'agents.description.opencode' | 'agents.description.openclaw' | 'agents.description.hermes' | 'agents.description.deepseekHarness' | 'agents.description.zcode' | 'agents.description.workbuddy' | 'agents.description.kimiCode' | 'agents.description.grokBuild' | 'agents.description.pi';
};
type AgentSubpageId = 'core' | 'management' | 'sessions';
@@ -282,6 +284,12 @@ const agentDefinitions: AgentDefinition[] = [
icon: grokIcon,
descriptionKey: 'agents.description.grokBuild',
},
{
id: 'workbuddy',
name: 'WorkBuddy',
icon: workbuddyIcon,
descriptionKey: 'agents.description.workbuddy',
},
{
id: 'zcode',
name: 'ZCode',
@@ -1210,6 +1218,8 @@ export function AgentsPage({ embedded = false, onConfigurationApplied }: AgentsP
const modificationDescription = activeStatus?.modificationState === 'invalid'
? t('agents.modify.invalid')
: nativeOauth ? t('agents.nativeOAuth.activeHint')
: selected === 'workbuddy'
? t('agents.modify.workbuddyHint')
: selected === 'zcode'
? t('agents.modify.zcodeRestart')
: '';
@@ -1731,7 +1741,7 @@ export function AgentsPage({ embedded = false, onConfigurationApplied }: AgentsP
};
const restartDesktopApp = async () => {
if (!['codex', 'opencode', 'claude-desktop', 'zcode'].includes(selected)) return;
if (!['codex', 'opencode', 'claude-desktop', 'zcode', 'workbuddy'].includes(selected)) return;
setBusyAction('restart-app');
setLaunchError('');
try {
@@ -2426,7 +2436,7 @@ export function AgentsPage({ embedded = false, onConfigurationApplied }: AgentsP
<MessageNotice tone="success" message={!configurationErrorMessage ? configurationNotice || clearNotice : null}
onDismiss={() => { setConfigurationNotice(''); setClearNotice(''); }} />
{activeSubpage === 'core' ? <AgentRunControls name={activeDefinition.name} dualTargets={hasIndependentCliAndApp}
desktop={hasIndependentCliAndApp || selected === 'claude-desktop' || selected === 'zcode'}
desktop={hasIndependentCliAndApp || selected === 'claude-desktop' || selected === 'zcode' || selected === 'workbuddy'}
targets={activeLaunchTargets} enabled={launchEnabled} busyAction={busyAction}
harness={isDeepSeekHarnessClient ? deepSeekHarnessProcessStatus : null}
onLaunch={(target) => void launchAgent(target)} onRestart={() => void restartDesktopApp()}
+2 -2
View File
@@ -66,7 +66,7 @@ const path = require('node:path');
assert.equal(await tab('基础配置').getAttribute('aria-selected'), 'true');
assert.equal(await tab('会话管理').count(), 0);
for (const client of ['claude-desktop', 'zcode', 'opencode']) {
for (const client of ['claude-desktop', 'zcode', 'workbuddy', 'opencode']) {
await open(mode + 'client=' + client + '&app-only');
assert.ok(await button('重启 App').isEnabled());
await button('重启 App').click();
@@ -80,7 +80,7 @@ const path = require('node:path');
await open(mode + 'client=kimi-code');
assert.equal(await button('重启 App').count(), 0);
for (const client of ['claude-code', 'claude-desktop', 'opencode', 'openclaw', 'hermes', 'deepseek-harness', 'zcode', 'kimi-code', 'grok-build']) {
for (const client of ['claude-code', 'claude-desktop', 'opencode', 'openclaw', 'hermes', 'deepseek-harness', 'zcode', 'workbuddy', 'kimi-code', 'grok-build']) {
await open(mode + 'client=' + client);
assert.ok(await button('关闭配置修改').isEnabled());
await tab('配置管理').click();
+2 -2
View File
@@ -9,7 +9,7 @@ if(params.has('reset-selections'))localStorage.removeItem('cpa-gui.agent-model-s
localStorage.setItem('easy-cli-proxy-api.locale',params.get('locale') || 'zh-CN');
document.documentElement.dataset.theme = params.get('theme') || 'light';
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'];
const ids = ['claude-code','claude-desktop','codex','opencode','openclaw','hermes','deepseek-harness','workbuddy','zcode','kimi-code','grok-build','pi'];
let count=0; let backupCount=0; const backups:any[]=[]; let currentModel=params.has('fresh')?null:'gpt-one';
let nativeOauth=params.has('native-oauth'); let nativeSwitchCount=0; let codexClosed=false; let closeCount=0;
let currentOauth=false; const currentMappings:Record<string,any>={};
@@ -35,7 +35,7 @@ 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:!params.has('not-installed'),pluginInstalled:!params.has('no-plugin'),launchTargets:params.has('not-installed')?[]:['claude-desktop','zcode'].includes(id)?[{id:'app',label:id,detail:'test desktop'}]:['codex','opencode'].includes(id)&&!params.has('cli-only')?[...(params.has('app-only')?[]:[{id:'cli',label:'CLI',detail:'test CLI'}]),{id:'app',label:'APP',detail:'test desktop'}]:[{id:'cli',label:'CLI',detail:'test CLI'}],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:id==='codex'&&currentOauth,codexNativeOauth:id==='codex'&&nativeOauth,modificationEnabled:!!currentModel,modificationState:currentModel?'applied':'unconfigured',backupAvailable:false,appliedModel:currentModel,claudeCodeModelMappings:id==='claude-code'?currentMappings[id]??null:null,claudeDesktopModelMappings:id==='claude-desktop'?currentMappings[id]??null:null,warnings:[],error:null})).map(status=>status.id==='codex'&&(nativeOauth||codexClosed)?{...status,configured:false,connectionState:'not-configured',configurationSynchronized:false,currentModel:null,appliedModel:null,modificationEnabled:false,modificationState:'unconfigured',oauthConfiguration:false}:status);
if(cmd==='get_agent_config_statuses'||cmd==='refresh_agent_config_statuses') return ids.map(id=>({id,name:id,supportedPlatform:true,installed:!params.has('not-installed'),pluginInstalled:!params.has('no-plugin'),launchTargets:params.has('not-installed')?[]:['claude-desktop','zcode','workbuddy'].includes(id)?[{id:'app',label:id,detail:'test desktop'}]:['codex','opencode'].includes(id)&&!params.has('cli-only')?[...(params.has('app-only')?[]:[{id:'cli',label:'CLI',detail:'test CLI'}]),{id:'app',label:'APP',detail:'test desktop'}]:[{id:'cli',label:'CLI',detail:'test CLI'}],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:id==='codex'&&currentOauth,codexNativeOauth:id==='codex'&&nativeOauth,modificationEnabled:!!currentModel,modificationState:currentModel?'applied':'unconfigured',backupAvailable:false,appliedModel:currentModel,claudeCodeModelMappings:id==='claude-code'?currentMappings[id]??null:null,claudeDesktopModelMappings:id==='claude-desktop'?currentMappings[id]??null:null,warnings:[],error:null})).map(status=>status.id==='codex'&&(nativeOauth||codexClosed)?{...status,configured:false,connectionState:'not-configured',configurationSynchronized:false,currentModel:null,appliedModel:null,modificationEnabled:false,modificationState:'unconfigured',oauthConfiguration:false}:status);
if(cmd==='close_codex_config_modification') {
closeCount++;
if(params.has('fail-close')&&closeCount===1)throw new Error('模拟关闭失败,已回滚');