fix(claude-code): persist runtime settings and clean up managed configuration
- Persist auto-compaction preferences and allow reducing 1M windows - Remove stale managed settings while preserving user changes - Normalize Fable model comparisons - Add regression tests and real Claude Code integration coverage
This commit is contained in:
1 parent
f7e9bd9884
commit
259389f2b2
9 files changed
+578
-12
No files matched your search
@@ -578,6 +578,13 @@ pub(crate) fn build_claude_agent_config(
|
||||
let root = root
|
||||
.as_object_mut()
|
||||
.ok_or_else(|| "Claude Code settings.json root must be an object".to_string())?;
|
||||
// Track only the compaction windows CPA owns so route changes and closing
|
||||
// the connection can remove them without deleting unrelated model settings.
|
||||
let previous_managed_models = root.get("env").and_then(serde_json::Value::as_object)
|
||||
.and_then(|env| env.get(CLAUDE_CODE_MANAGED_MODEL_SETTINGS_ENV))
|
||||
.and_then(serde_json::Value::as_str)
|
||||
.and_then(|value| serde_json::from_str::<Vec<String>>(value).ok())
|
||||
.unwrap_or_default();
|
||||
let mappings = mappings
|
||||
.cloned()
|
||||
.unwrap_or_else(|| ClaudeDesktopModelMappings::all(model));
|
||||
@@ -667,6 +674,13 @@ pub(crate) fn build_claude_agent_config(
|
||||
CLAUDE_CODE_AUTO_MODE_SERVER_ENV.to_string(),
|
||||
serde_json::Value::String("0".to_string()),
|
||||
);
|
||||
for (key, value) in [
|
||||
(CLAUDE_CODE_MAX_CONTEXT_TOKENS_ENV, max_context_tokens.to_string()),
|
||||
(CLAUDE_AUTOCOMPACT_PCT_OVERRIDE_ENV, mappings.auto_compact_pct.to_string()),
|
||||
(DISABLE_AUTO_COMPACT_ENV, if mappings.disable_auto_compact { "1" } else { "0" }.to_string()),
|
||||
] {
|
||||
env.insert(key.to_string(), serde_json::Value::String(value));
|
||||
}
|
||||
for (key, value) in claude_code_model_presentation_environment(&mappings, models)? {
|
||||
env.insert(key, serde_json::Value::String(value));
|
||||
}
|
||||
@@ -682,7 +696,7 @@ pub(crate) fn build_claude_agent_config(
|
||||
// official settings so newer versions can apply the value per model.
|
||||
root.insert("autoCompactWindow".into(), serde_json::Value::Number(max_context_tokens.into()));
|
||||
root.insert("autoCompactEnabled".into(), serde_json::Value::Bool(!mappings.disable_auto_compact));
|
||||
let model_settings_json = ensure_json_object_entry(root, "modelSettings");
|
||||
let mut model_windows = std::collections::BTreeMap::<String, u64>::new();
|
||||
for (model_id, window) in [
|
||||
(&mappings.fable, if mappings.fable_1m { CLAUDE_DESKTOP_EXTENDED_CONTEXT_WINDOW } else { mappings.max_context_tokens }),
|
||||
(&mappings.opus, if mappings.opus_1m { CLAUDE_DESKTOP_EXTENDED_CONTEXT_WINDOW } else { mappings.max_context_tokens }),
|
||||
@@ -691,9 +705,8 @@ pub(crate) fn build_claude_agent_config(
|
||||
] {
|
||||
let model_id = strip_claude_code_context_suffix(model_id).trim();
|
||||
if model_id.is_empty() { continue; }
|
||||
let entry = ensure_json_object_entry(model_settings_json, model_id);
|
||||
let current = entry.get("autoCompactWindow").and_then(serde_json::Value::as_u64).unwrap_or(0);
|
||||
entry.insert("autoCompactWindow".into(), serde_json::Value::Number(current.max(window).into()));
|
||||
let current = model_windows.entry(model_id.to_string()).or_default();
|
||||
*current = (*current).max(window);
|
||||
}
|
||||
for model in [&mappings.startup_model, &mappings.subagent_model] {
|
||||
let Some(model) = model.as_deref().map(str::trim).filter(|value| !value.is_empty()) else { continue; };
|
||||
@@ -703,9 +716,21 @@ pub(crate) fn build_claude_agent_config(
|
||||
} else {
|
||||
mappings.max_context_tokens
|
||||
};
|
||||
let entry = ensure_json_object_entry(model_settings_json, model_id);
|
||||
let current = entry.get("autoCompactWindow").and_then(serde_json::Value::as_u64).unwrap_or(0);
|
||||
entry.insert("autoCompactWindow".into(), serde_json::Value::Number(current.max(window).into()));
|
||||
let current = model_windows.entry(model_id.to_string()).or_default();
|
||||
*current = (*current).max(window);
|
||||
}
|
||||
let managed_model_ids = model_windows.keys().cloned().collect::<Vec<_>>();
|
||||
let model_settings_json = ensure_json_object_entry(root, "modelSettings");
|
||||
for model in previous_managed_models {
|
||||
if model_windows.contains_key(&model) { continue; }
|
||||
if let Some(entry) = model_settings_json.get_mut(&model).and_then(serde_json::Value::as_object_mut) {
|
||||
entry.remove("autoCompactWindow");
|
||||
if entry.is_empty() { model_settings_json.remove(&model); }
|
||||
}
|
||||
}
|
||||
for (model_id, window) in model_windows {
|
||||
let entry = ensure_json_object_entry(model_settings_json, &model_id);
|
||||
entry.insert("autoCompactWindow".into(), serde_json::Value::Number(window.into()));
|
||||
}
|
||||
if let Some(model) = explicit_startup_model {
|
||||
if model.is_empty() {
|
||||
@@ -726,6 +751,19 @@ pub(crate) fn build_claude_agent_config(
|
||||
{
|
||||
root.remove("model");
|
||||
}
|
||||
// The saved selection identifies a CPA default separately from a later
|
||||
// user change made through Claude Code's own /model picker.
|
||||
let managed_startup = root.get("model").and_then(serde_json::Value::as_str)
|
||||
.filter(|_| mappings.startup_model.as_ref().map_or(mappings.manage_default_model, |model| !model.trim().is_empty()))
|
||||
.map(str::to_string);
|
||||
let env = ensure_json_object_entry(root, "env");
|
||||
env.insert(CLAUDE_CODE_MANAGED_MODEL_SETTINGS_ENV.into(), serde_json::Value::String(
|
||||
serde_json::to_string(&managed_model_ids).map_err(|_| "Failed to record managed Claude Code models".to_string())?));
|
||||
if let Some(model) = managed_startup {
|
||||
env.insert(CLAUDE_CODE_MANAGED_STARTUP_MODEL_ENV.into(), serde_json::Value::String(model));
|
||||
} else {
|
||||
env.remove(CLAUDE_CODE_MANAGED_STARTUP_MODEL_ENV);
|
||||
}
|
||||
let mut rendered = serde_json::to_string_pretty(&serde_json::Value::Object(root.clone()))
|
||||
.map_err(|error| format!("Failed to generate Claude Code configuration: {error}"))?;
|
||||
rendered.push('\n');
|
||||
@@ -1205,7 +1243,7 @@ pub(crate) fn prepare_claude_code_managed_removal(
|
||||
let managed_model = root
|
||||
.get("env")
|
||||
.and_then(serde_json::Value::as_object)
|
||||
.and_then(|env| env.get("ANTHROPIC_MODEL"))
|
||||
.and_then(|env| env.get(CLAUDE_CODE_MANAGED_STARTUP_MODEL_ENV).or_else(|| env.get("ANTHROPIC_MODEL")))
|
||||
.and_then(serde_json::Value::as_str)
|
||||
.map(str::to_string);
|
||||
let managed = root
|
||||
@@ -1219,7 +1257,21 @@ pub(crate) fn prepare_claude_code_managed_removal(
|
||||
if !managed {
|
||||
return false;
|
||||
}
|
||||
if root.get("model").and_then(serde_json::Value::as_str) == managed_model.as_deref() {
|
||||
let has_managed_model_record = root.get("env").and_then(serde_json::Value::as_object)
|
||||
.is_some_and(|env| env.contains_key(CLAUDE_CODE_MANAGED_STARTUP_MODEL_ENV));
|
||||
let managed_role_selection = !has_managed_model_record && root.get("model").and_then(serde_json::Value::as_str)
|
||||
.is_some_and(|model| {
|
||||
let base = strip_claude_code_context_suffix(model);
|
||||
matches!(base.to_ascii_lowercase().as_str(), "opus" | "sonnet" | "haiku" | "fable")
|
||||
|| root.get("env").and_then(serde_json::Value::as_object).is_some_and(|env| {
|
||||
["ANTHROPIC_DEFAULT_OPUS_MODEL", "ANTHROPIC_DEFAULT_SONNET_MODEL",
|
||||
"ANTHROPIC_DEFAULT_HAIKU_MODEL", "ANTHROPIC_DEFAULT_FABLE_MODEL"]
|
||||
.iter().any(|key| env.get(*key).and_then(serde_json::Value::as_str)
|
||||
.is_some_and(|route| strip_claude_code_context_suffix(route) == base))
|
||||
})
|
||||
});
|
||||
restore_claude_code_compaction_settings(root, None);
|
||||
if managed_role_selection || root.get("model").and_then(serde_json::Value::as_str) == managed_model.as_deref() {
|
||||
root.remove("model");
|
||||
}
|
||||
let env_empty = if let Some(env) = root
|
||||
@@ -1242,6 +1294,8 @@ pub(crate) fn prepare_claude_code_managed_removal(
|
||||
CLAUDE_CODE_AUTO_MODE_SERVER_ENV,
|
||||
CLAUDE_AUTOCOMPACT_PCT_OVERRIDE_ENV,
|
||||
DISABLE_AUTO_COMPACT_ENV,
|
||||
CLAUDE_CODE_MANAGED_MODEL_SETTINGS_ENV,
|
||||
CLAUDE_CODE_MANAGED_STARTUP_MODEL_ENV,
|
||||
"CLAUDE_CODE_SUBAGENT_MODEL",
|
||||
"CLAUDE_CODE_EFFORT_LEVEL",
|
||||
"ANTHROPIC_DEFAULT_OPUS_MODEL_NAME",
|
||||
@@ -1843,12 +1897,58 @@ pub(crate) fn render_restored_json(
|
||||
}
|
||||
}
|
||||
|
||||
fn restore_claude_code_compaction_settings(
|
||||
root: &mut serde_json::Map<String, serde_json::Value>,
|
||||
original: Option<&serde_json::Map<String, serde_json::Value>>,
|
||||
) {
|
||||
let env = root.get("env").and_then(serde_json::Value::as_object);
|
||||
let mut managed_models = [
|
||||
"ANTHROPIC_DEFAULT_OPUS_MODEL", "ANTHROPIC_DEFAULT_SONNET_MODEL",
|
||||
"ANTHROPIC_DEFAULT_HAIKU_MODEL", "ANTHROPIC_DEFAULT_FABLE_MODEL",
|
||||
"ANTHROPIC_MODEL", "CLAUDE_CODE_SUBAGENT_MODEL",
|
||||
].iter().filter_map(|key| env?.get(*key)?.as_str())
|
||||
.chain(root.get("model").and_then(serde_json::Value::as_str))
|
||||
.map(|model| strip_claude_code_context_suffix(model).to_string())
|
||||
.collect::<Vec<_>>();
|
||||
if let Some(recorded) = env.and_then(|env| env.get(CLAUDE_CODE_MANAGED_MODEL_SETTINGS_ENV))
|
||||
.and_then(serde_json::Value::as_str)
|
||||
.and_then(|value| serde_json::from_str::<Vec<String>>(value).ok()) {
|
||||
managed_models = recorded;
|
||||
}
|
||||
managed_models.sort();
|
||||
managed_models.dedup();
|
||||
for key in ["autoCompactWindow", "autoCompactEnabled"] {
|
||||
restore_json_key(root, original, key);
|
||||
}
|
||||
let original_models = original.and_then(|root| root.get("modelSettings"));
|
||||
if original_models.is_some_and(|value| !value.is_object()) {
|
||||
restore_json_key(root, original, "modelSettings");
|
||||
return;
|
||||
}
|
||||
let original_models = original_models.and_then(serde_json::Value::as_object);
|
||||
if let Some(models) = root.get_mut("modelSettings").and_then(serde_json::Value::as_object_mut) {
|
||||
for model in managed_models {
|
||||
let original_entry = original_models.and_then(|models| models.get(&model));
|
||||
if let Some(entry) = models.get_mut(&model).and_then(serde_json::Value::as_object_mut) {
|
||||
restore_json_key(entry, original_entry.and_then(serde_json::Value::as_object), "autoCompactWindow");
|
||||
if entry.is_empty() && original_entry.is_none() {
|
||||
models.remove(&model);
|
||||
}
|
||||
}
|
||||
}
|
||||
if models.is_empty() && original_models.is_none() {
|
||||
root.remove("modelSettings");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn build_restored_claude_code_config(
|
||||
current: &str,
|
||||
original: Option<&str>,
|
||||
) -> Result<Option<String>, String> {
|
||||
let mut root = parse_agent_json_object(Some(current), "Current Claude Code configuration")?;
|
||||
let original_root = parse_restored_json_object(original, "Original Claude Code configuration")?;
|
||||
restore_claude_code_compaction_settings(&mut root, original_root.as_ref());
|
||||
restore_json_key(&mut root, original_root.as_ref(), "model");
|
||||
let original_env = original_root
|
||||
.as_ref()
|
||||
@@ -1875,6 +1975,8 @@ pub(crate) fn build_restored_claude_code_config(
|
||||
CLAUDE_CODE_AUTO_MODE_SERVER_ENV,
|
||||
CLAUDE_AUTOCOMPACT_PCT_OVERRIDE_ENV,
|
||||
DISABLE_AUTO_COMPACT_ENV,
|
||||
CLAUDE_CODE_MANAGED_MODEL_SETTINGS_ENV,
|
||||
CLAUDE_CODE_MANAGED_STARTUP_MODEL_ENV,
|
||||
"CLAUDE_CODE_SUBAGENT_MODEL",
|
||||
"CLAUDE_CODE_EFFORT_LEVEL",
|
||||
"ANTHROPIC_DEFAULT_OPUS_MODEL_NAME",
|
||||
|
||||
@@ -854,6 +854,8 @@ fn validate_unmanaged_preserved(
|
||||
!matches!(
|
||||
key.as_str(),
|
||||
"CLAUDE_CODE_SUBAGENT_MODEL"
|
||||
| CLAUDE_CODE_MANAGED_MODEL_SETTINGS_ENV
|
||||
| CLAUDE_CODE_MANAGED_STARTUP_MODEL_ENV
|
||||
| "CLAUDE_CODE_EFFORT_LEVEL"
|
||||
| "EASYCLIPROXY_MANAGE_CLAUDE_CODE_DEFAULT_MODEL"
|
||||
| "EASYCLIPROXY_MANAGE_CLAUDE_CODE_SUBAGENT_MODEL"
|
||||
|
||||
@@ -206,6 +206,8 @@ const CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY_ENV: &str =
|
||||
const CLAUDE_CODE_AUTO_MODE_SERVER_ENV: &str = "CLAUDE_CODE_AUTO_MODE_SERVER";
|
||||
const CLAUDE_AUTOCOMPACT_PCT_OVERRIDE_ENV: &str = "CLAUDE_AUTOCOMPACT_PCT_OVERRIDE";
|
||||
const DISABLE_AUTO_COMPACT_ENV: &str = "DISABLE_AUTO_COMPACT";
|
||||
const CLAUDE_CODE_MANAGED_MODEL_SETTINGS_ENV: &str = "EASYCLIPROXY_CLAUDE_CODE_MODEL_SETTINGS";
|
||||
const CLAUDE_CODE_MANAGED_STARTUP_MODEL_ENV: &str = "EASYCLIPROXY_CLAUDE_CODE_STARTUP_MODEL";
|
||||
const DEFAULT_CLAUDE_AUTO_COMPACT_PCT: u8 = 90;
|
||||
const MODEL_ALIAS_CONFIG_SECTIONS: &[&str] = &[
|
||||
"codex-api-key",
|
||||
@@ -1167,7 +1169,7 @@ struct ClaudeDesktopModelMappings {
|
||||
/// Defaults to true for backwards compatibility with existing profiles.
|
||||
#[serde(default = "default_true")]
|
||||
manage_subagent_model: bool,
|
||||
/// Optional explicit model written to ANTHROPIC_MODEL.
|
||||
/// Optional explicit model written to the root Claude Code `model` setting.
|
||||
#[serde(default)]
|
||||
startup_model: Option<String>,
|
||||
/// Optional explicit model written to CLAUDE_CODE_SUBAGENT_MODEL.
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
use super::*;
|
||||
|
||||
mod agent_configuration;
|
||||
mod claude_code_live;
|
||||
mod deepseek_harness_catalog;
|
||||
mod deepseek_desktop;
|
||||
mod agent_paths;
|
||||
|
||||
@@ -0,0 +1,225 @@
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn claude_code_compaction_percentage_is_written_and_round_trips() {
|
||||
let directory = super::support::agent_test_home("compact-percentage");
|
||||
let path = directory.join("settings.json");
|
||||
let mut mappings = ClaudeDesktopModelMappings::all("route-model");
|
||||
mappings.auto_compact_pct = 75;
|
||||
let rendered = build_claude_agent_config(
|
||||
None,
|
||||
"http://127.0.0.1:8317",
|
||||
"test-key",
|
||||
"route-model",
|
||||
&super::support::test_agent_models(&["route-model"]),
|
||||
Some(&mappings),
|
||||
)
|
||||
.unwrap();
|
||||
let value: serde_json::Value = serde_json::from_str(&rendered).unwrap();
|
||||
assert_eq!(value["env"][CLAUDE_AUTOCOMPACT_PCT_OVERRIDE_ENV], "75");
|
||||
fs::write(&path, rendered).unwrap();
|
||||
assert_eq!(
|
||||
inspect_claude_code_model_mappings(&path)
|
||||
.unwrap()
|
||||
.unwrap()
|
||||
.auto_compact_pct,
|
||||
75
|
||||
);
|
||||
fs::remove_dir_all(directory).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn claude_code_context_window_can_be_reduced_after_disabling_1m() {
|
||||
let models = super::support::test_agent_models(&["route-model"]);
|
||||
let mut mappings = ClaudeDesktopModelMappings::all("route-model");
|
||||
mappings.opus_1m = true;
|
||||
mappings.max_context_tokens = 1_000_000;
|
||||
mappings.startup_model = Some("opus".into());
|
||||
let extended = build_claude_agent_config(
|
||||
None,
|
||||
"http://127.0.0.1:8317",
|
||||
"test-key",
|
||||
"route-model",
|
||||
&models,
|
||||
Some(&mappings),
|
||||
)
|
||||
.unwrap();
|
||||
mappings.opus_1m = false;
|
||||
mappings.max_context_tokens = 200_000;
|
||||
let reduced = build_claude_agent_config(
|
||||
Some(&extended),
|
||||
"http://127.0.0.1:8317",
|
||||
"test-key",
|
||||
"route-model",
|
||||
&models,
|
||||
Some(&mappings),
|
||||
)
|
||||
.unwrap();
|
||||
let value: serde_json::Value = serde_json::from_str(&reduced).unwrap();
|
||||
assert_eq!(
|
||||
value["modelSettings"]["route-model"]["autoCompactWindow"],
|
||||
200_000
|
||||
);
|
||||
assert_eq!(value["modelSettings"]["opus"]["autoCompactWindow"], 200_000);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn claude_code_restore_restores_compaction_settings_and_preserves_unrelated_model_metadata() {
|
||||
let original = r#"{"autoCompactWindow":150000,"autoCompactEnabled":false,"modelSettings":{"route-model":{"autoCompactWindow":125000,"effortLevel":"low"},"user-model":{"autoCompactWindow":120000}}}"#;
|
||||
let generated = build_claude_agent_config(
|
||||
Some(original),
|
||||
"http://127.0.0.1:8317",
|
||||
"test-key",
|
||||
"route-model",
|
||||
&super::support::test_agent_models(&["route-model"]),
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
let restored = build_restored_claude_code_config(&generated, Some(original))
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
let value: serde_json::Value = serde_json::from_str(&restored).unwrap();
|
||||
assert_eq!(
|
||||
value,
|
||||
serde_json::from_str::<serde_json::Value>(original).unwrap()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn claude_code_clear_removes_explicit_role_selection_and_preserves_user_model_metadata() {
|
||||
let directory = super::support::agent_test_home("clear-explicit-role");
|
||||
let path = directory.join("settings.json");
|
||||
let models = super::support::test_agent_models(&["route-model"]);
|
||||
let mut mappings = ClaudeDesktopModelMappings::all("route-model");
|
||||
mappings.startup_model = Some("opus".into());
|
||||
mappings.subagent_model = Some(String::new());
|
||||
let rendered = build_claude_agent_config(
|
||||
Some(r#"{"modelSettings":{"route-model":{"effortLevel":"low"},"user-model":{"autoCompactWindow":150000}}}"#),
|
||||
"http://127.0.0.1:8317", "test-key", "route-model", &models, Some(&mappings)).unwrap();
|
||||
fs::write(&path, rendered).unwrap();
|
||||
let cleared = prepare_claude_code_managed_removal(&[path], "http://127.0.0.1:8317").unwrap();
|
||||
let value: serde_json::Value = serde_json::from_slice(cleared[0].1.as_ref().unwrap()).unwrap();
|
||||
assert!(value.get("model").is_none());
|
||||
assert_eq!(
|
||||
value["modelSettings"]["route-model"],
|
||||
serde_json::json!({"effortLevel":"low"})
|
||||
);
|
||||
assert_eq!(
|
||||
value["modelSettings"]["user-model"],
|
||||
serde_json::json!({"autoCompactWindow":150000})
|
||||
);
|
||||
fs::remove_dir_all(directory).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn claude_code_clears_old_windows_after_route_changes_and_custom_startup_on_close() {
|
||||
let directory = super::support::agent_test_home("clear-changed-routes");
|
||||
let path = directory.join("settings.json");
|
||||
let models = super::support::test_agent_models(&["route-a", "route-b"]);
|
||||
let first = build_claude_agent_config(
|
||||
None,
|
||||
"http://127.0.0.1:8317",
|
||||
"test-key",
|
||||
"route-a",
|
||||
&models,
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
let mut mappings = ClaudeDesktopModelMappings::all("route-b");
|
||||
mappings.startup_model = Some("custom-startup".into());
|
||||
mappings.subagent_model = Some(String::new());
|
||||
let updated = build_claude_agent_config(
|
||||
Some(&first),
|
||||
"http://127.0.0.1:8317",
|
||||
"test-key",
|
||||
"route-b",
|
||||
&models,
|
||||
Some(&mappings),
|
||||
)
|
||||
.unwrap();
|
||||
let value: serde_json::Value = serde_json::from_str(&updated).unwrap();
|
||||
assert!(value["modelSettings"].get("route-a").is_none());
|
||||
fs::write(&path, updated).unwrap();
|
||||
let cleared =
|
||||
prepare_claude_code_managed_removal(&[path.clone()], "http://127.0.0.1:8317").unwrap();
|
||||
assert_eq!(cleared, vec![(path, None)]);
|
||||
fs::remove_dir_all(directory).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn claude_code_clear_preserves_later_user_selection_and_its_context_window() {
|
||||
let directory = super::support::agent_test_home("clear-user-model-edit");
|
||||
let path = directory.join("settings.json");
|
||||
let mut mappings = ClaudeDesktopModelMappings::all("route-model");
|
||||
mappings.startup_model = Some("opus".into());
|
||||
let rendered = build_claude_agent_config(
|
||||
None,
|
||||
"http://127.0.0.1:8317",
|
||||
"test-key",
|
||||
"route-model",
|
||||
&super::support::test_agent_models(&["route-model"]),
|
||||
Some(&mappings),
|
||||
)
|
||||
.unwrap();
|
||||
let mut edited: serde_json::Value = serde_json::from_str(&rendered).unwrap();
|
||||
edited["model"] = serde_json::json!("sonnet");
|
||||
edited["modelSettings"]["sonnet"] = serde_json::json!({"autoCompactWindow":150000});
|
||||
fs::write(&path, edited.to_string()).unwrap();
|
||||
let cleared = prepare_claude_code_managed_removal(&[path], "http://127.0.0.1:8317").unwrap();
|
||||
let value: serde_json::Value = serde_json::from_slice(cleared[0].1.as_ref().unwrap()).unwrap();
|
||||
assert_eq!(value["model"], "sonnet");
|
||||
assert_eq!(
|
||||
value["modelSettings"]["sonnet"]["autoCompactWindow"],
|
||||
150_000
|
||||
);
|
||||
fs::remove_dir_all(directory).unwrap();
|
||||
}
|
||||
|
||||
// The Node live runner provides only model IDs and a loopback observer URL.
|
||||
// Real credentials stay in that runner's memory and never enter these fixtures.
|
||||
#[test]
|
||||
#[ignore = "invoked by tests/claude-code-live.cjs with isolated fixture paths"]
|
||||
fn export_claude_code_live_configurations() {
|
||||
let request_path = std::env::var("EZCPA_CLAUDE_LIVE_REQUEST").expect("live request path");
|
||||
let output_path = std::env::var("EZCPA_CLAUDE_LIVE_OUTPUT").expect("live output path");
|
||||
let request: serde_json::Value =
|
||||
serde_json::from_slice(&fs::read(request_path).unwrap()).unwrap();
|
||||
let models: Vec<AgentModelOption> = serde_json::from_value(request["models"].clone()).unwrap();
|
||||
let base_url = request["baseUrl"].as_str().unwrap();
|
||||
let mut outputs = Vec::new();
|
||||
let mut previous_settings = std::collections::BTreeMap::<String, String>::new();
|
||||
for case in request["cases"].as_array().unwrap() {
|
||||
let mappings: ClaudeDesktopModelMappings =
|
||||
serde_json::from_value(case["mappings"].clone()).unwrap();
|
||||
let resolved = resolve_claude_code_model_mappings(
|
||||
AgentClient::ClaudeCode,
|
||||
&models,
|
||||
&mappings.sonnet,
|
||||
Some(mappings.clone()),
|
||||
)
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
let existing = case
|
||||
.get("existingFrom")
|
||||
.and_then(serde_json::Value::as_str)
|
||||
.and_then(|id| previous_settings.get(id).cloned())
|
||||
.or_else(|| {
|
||||
case.get("existing")
|
||||
.filter(|value| !value.is_null())
|
||||
.map(|value| value.to_string())
|
||||
});
|
||||
let rendered = build_claude_agent_config(
|
||||
existing.as_deref(),
|
||||
base_url,
|
||||
"isolated-live-test",
|
||||
&resolved.sonnet,
|
||||
&models,
|
||||
Some(&resolved),
|
||||
)
|
||||
.unwrap();
|
||||
let settings: serde_json::Value = serde_json::from_str(&rendered).unwrap();
|
||||
previous_settings.insert(case["id"].as_str().unwrap().to_string(), rendered);
|
||||
outputs.push(serde_json::json!({ "id": case["id"], "settings": settings }));
|
||||
}
|
||||
fs::write(output_path, serde_json::to_vec_pretty(&outputs).unwrap()).unwrap();
|
||||
}
|
||||
@@ -71,7 +71,7 @@ export const sameAgentModelMappings = (
|
||||
&& sameAgentModel(left.haiku, right.haiku)
|
||||
&& Boolean(left.opus1m) === Boolean(right.opus1m)
|
||||
&& Boolean(left.sonnet1m) === Boolean(right.sonnet1m)
|
||||
&& (left.fable ?? left.sonnet) === (right.fable ?? right.sonnet)
|
||||
&& sameAgentModel(left.fable ?? left.sonnet, right.fable ?? right.sonnet)
|
||||
&& Boolean(left.fable1m) === Boolean(right.fable1m)
|
||||
&& Boolean(left.haiku1m) === Boolean(right.haiku1m)
|
||||
&& (left.maxContextTokens ?? 200_000) === (right.maxContextTokens ?? 200_000)
|
||||
|
||||
@@ -100,6 +100,11 @@ const appliedConfiguration = (
|
||||
});
|
||||
|
||||
describe('agent configuration update action', () => {
|
||||
test('Fable mappings compare names with the same normalization as other roles', () => {
|
||||
const baseline = { opus: 'model-a', sonnet: 'model-a', haiku: 'model-a', fable: 'model-fable' };
|
||||
expect(sameAgentModelMappings({ ...baseline, fable: ' MODEL-FABLE ' }, baseline)).toBeTrue();
|
||||
expect(sameAgentModelMappings({ ...baseline, fable: 'model-other' }, baseline)).toBeFalse();
|
||||
});
|
||||
test('Claude mapping clients keep their own unsaved draft after switching away and back', () => {
|
||||
const codeDraft = { opus: 'code-opus', sonnet: 'code-sonnet', haiku: 'code-haiku' };
|
||||
const desktopDraft = {
|
||||
|
||||
@@ -0,0 +1,222 @@
|
||||
// Opt-in integration test: runs the installed Claude Code against the configured CPA.
|
||||
// Usage: node tests/claude-code-live.cjs [case-id ...]
|
||||
// CLAUDE_LIVE_EXE may override the installed executable. No secrets are logged.
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const os = require('node:os');
|
||||
const http = require('node:http');
|
||||
const { spawn, spawnSync } = require('node:child_process');
|
||||
const { Readable } = require('node:stream');
|
||||
const { createHash } = require('node:crypto');
|
||||
|
||||
const root = path.resolve(__dirname, '..');
|
||||
const stamp = new Date().toISOString().replace(/[:.]/g, '-');
|
||||
const output = path.join(root, '.codex', 'claude-code-live', stamp);
|
||||
fs.mkdirSync(output, { recursive: true });
|
||||
const userSettingsPath = path.join(os.homedir(), '.claude', 'settings.json');
|
||||
const userSettingsBytes = fs.readFileSync(userSettingsPath);
|
||||
const userSettings = JSON.parse(userSettingsBytes);
|
||||
const upstreamBase = userSettings.env?.ANTHROPIC_BASE_URL;
|
||||
const upstreamToken = userSettings.env?.ANTHROPIC_AUTH_TOKEN || userSettings.env?.ANTHROPIC_API_KEY;
|
||||
assert(upstreamBase && upstreamToken, 'CPA endpoint and credential must be configured');
|
||||
assert(['127.0.0.1', 'localhost', '[::1]'].includes(new URL(upstreamBase).hostname), 'Live test requires a local CPA');
|
||||
const digest = bytes => createHash('sha256').update(bytes).digest('hex');
|
||||
const protectedPaths = [userSettingsPath, path.join(os.homedir(), '.claude.json'), path.join(os.homedir(), '.claude', '.credentials.json')];
|
||||
const protectedHashes = new Map(protectedPaths.filter(p => fs.existsSync(p)).map(p => [p, digest(fs.readFileSync(p))]));
|
||||
const redact = text => String(text).split(upstreamToken).join('[REDACTED]');
|
||||
|
||||
const executable = process.env.CLAUDE_LIVE_EXE || path.join(path.dirname(process.execPath), 'node_modules', '@anthropic-ai', 'claude-code', 'bin', 'claude.exe');
|
||||
assert(fs.existsSync(executable), 'Set CLAUDE_LIVE_EXE to the installed Claude Code executable');
|
||||
const version = spawnSync(executable, ['--version'], { encoding: 'utf8', windowsHide: true });
|
||||
assert.equal(version.status, 0);
|
||||
console.log(`Claude Code: ${version.stdout.trim()}`);
|
||||
console.log(`Evidence: ${output}`);
|
||||
|
||||
let activeCase = null;
|
||||
const observations = [];
|
||||
const observer = http.createServer(async (req, res) => {
|
||||
try {
|
||||
const chunks = [];
|
||||
for await (const chunk of req) chunks.push(chunk);
|
||||
const body = Buffer.concat(chunks);
|
||||
let message;
|
||||
if (req.url.split('?')[0] === '/v1/messages' && body.length) {
|
||||
const value = JSON.parse(body);
|
||||
message = {
|
||||
case: activeCase, path: req.url, model: value.model,
|
||||
maxTokens: value.max_tokens, stream: value.stream,
|
||||
beta: req.headers['anthropic-beta'] || '',
|
||||
toolNames: (value.tools || []).map(tool => tool.name),
|
||||
};
|
||||
observations.push(message);
|
||||
fs.writeFileSync(path.join(output, 'requests.json'), JSON.stringify(observations, null, 2));
|
||||
}
|
||||
const headers = { ...req.headers, authorization: `Bearer ${upstreamToken}`, 'x-api-key': upstreamToken };
|
||||
delete headers.host;
|
||||
delete headers['content-length'];
|
||||
delete headers.connection;
|
||||
delete headers['accept-encoding'];
|
||||
const response = await fetch(`${upstreamBase.replace(/\/$/, '')}${req.url}`, {
|
||||
method: req.method, headers, body: ['GET', 'HEAD'].includes(req.method) ? undefined : body,
|
||||
signal: AbortSignal.timeout(180000),
|
||||
});
|
||||
if (message) message.status = response.status;
|
||||
if (message) fs.writeFileSync(path.join(output, 'requests.json'), JSON.stringify(observations, null, 2));
|
||||
res.statusCode = response.status;
|
||||
for (const [key, value] of response.headers) {
|
||||
if (!['content-length', 'content-encoding', 'transfer-encoding', 'connection'].includes(key)) res.setHeader(key, value);
|
||||
}
|
||||
if (response.body) Readable.fromWeb(response.body).pipe(res);
|
||||
else res.end();
|
||||
} catch (error) {
|
||||
if (!res.headersSent) res.writeHead(502, { 'content-type': 'application/json' });
|
||||
res.end(JSON.stringify({ type: 'error', error: { type: 'api_error', message: redact(error.message) } }));
|
||||
}
|
||||
});
|
||||
|
||||
const runProcess = (program, args, options = {}) => new Promise(resolve => {
|
||||
const child = spawn(program, args, { windowsHide: true, ...options });
|
||||
let stdout = '', stderr = '', timedOut = false;
|
||||
child.stdout.on('data', chunk => { stdout += chunk; });
|
||||
child.stderr.on('data', chunk => { stderr += chunk; });
|
||||
const timer = setTimeout(() => { timedOut = true; child.kill(); }, options.timeout || 180000);
|
||||
child.on('error', error => { clearTimeout(timer); resolve({ code: -1, stdout, stderr: redact(error.message), timedOut }); });
|
||||
child.on('close', code => { clearTimeout(timer); resolve({ code, stdout: redact(stdout), stderr: redact(stderr), timedOut }); });
|
||||
});
|
||||
|
||||
(async () => {
|
||||
await new Promise(resolve => observer.listen(0, '127.0.0.1', resolve));
|
||||
const baseUrl = `http://127.0.0.1:${observer.address().port}`;
|
||||
const modelResponse = await fetch(`${upstreamBase.replace(/\/$/, '')}/v1/models`, { headers: { Authorization: `Bearer ${upstreamToken}` } });
|
||||
assert.equal(modelResponse.status, 200);
|
||||
const available = (await modelResponse.json()).data.map(model => model.id);
|
||||
const modelA = process.env.CLAUDE_LIVE_MODEL_A || 'gpt-6.1-sol';
|
||||
const modelB = process.env.CLAUDE_LIVE_MODEL_B || 'gpt-6-luna';
|
||||
const modelAlias = process.env.CLAUDE_LIVE_MODEL_ALIAS || 'gpt-6.1-sol-high-fast';
|
||||
assert(available.includes(modelA) && available.includes(modelB), 'Both live models must be available in CPA');
|
||||
const mapping = changes => ({ opus: modelA, sonnet: modelB, haiku: modelB, fable: modelA,
|
||||
startupModel: 'opus', subagentModel: '', maxContextTokens: 200000, autoCompactPct: 90,
|
||||
disableAutoCompact: false, ...changes });
|
||||
const smokePrompt = 'Reply with exactly EZCPA_LIVE_OK. Do not use tools.';
|
||||
const workerPrompt = 'Use the Agent tool to delegate this task to the live-worker subagent: reply with exactly EZCPA_WORKER_OK. Wait for the worker to finish, then reply EZCPA_PARENT_OK. You must call Agent; do not answer without delegation.';
|
||||
const cases = [
|
||||
{ id: 'default-opus', mappings: mapping({}), expected: modelA, prompt: smokePrompt },
|
||||
{ id: 'default-sonnet', mappings: mapping({ startupModel: 'sonnet' }), expected: modelB, prompt: smokePrompt },
|
||||
{ id: 'default-haiku', mappings: mapping({ startupModel: 'haiku' }), expected: modelB, prompt: smokePrompt },
|
||||
{ id: 'default-fable', mappings: mapping({ startupModel: 'fable' }), expected: modelA, prompt: smokePrompt },
|
||||
{ id: 'direct-model', mappings: mapping({ startupModel: modelB }), expected: modelB, prompt: smokePrompt },
|
||||
{ id: 'configured-alias', mappings: mapping({ opus: modelAlias }), expected: modelAlias, prompt: smokePrompt },
|
||||
{ id: 'updated-role-mapping', mappings: mapping({ startupModel: 'sonnet', sonnet: modelA }),
|
||||
existingFrom: 'default-sonnet', expected: modelA, prompt: smokePrompt },
|
||||
{ id: 'cli-model-override', mappings: mapping({}), expected: modelB, args: ['--model', 'sonnet'], prompt: smokePrompt },
|
||||
{ id: 'role-1m', mappings: mapping({ opus1m: true }), expected: modelA, context: 1000000, prompt: smokePrompt },
|
||||
{ id: 'direct-1m', mappings: mapping({ startupModel: `${modelA}[1m]` }), expected: modelA, context: 1000000, prompt: smokePrompt },
|
||||
{ id: 'after-disable-1m', mappings: mapping({}), existingFrom: 'role-1m', expected: modelA, context: 200000, prompt: smokePrompt },
|
||||
{ id: 'compact-policy', mappings: mapping({ maxContextTokens: 300000, autoCompactPct: 75, disableAutoCompact: true }),
|
||||
expected: modelA, context: 300000, prompt: smokePrompt, debug: true },
|
||||
{ id: 'compact-enabled-policy', mappings: mapping({ maxContextTokens: 300000, autoCompactPct: 75 }),
|
||||
expected: modelA, context: 300000, prompt: smokePrompt, debug: true },
|
||||
{ id: 'agent-file-tools', mappings: mapping({ startupModel: 'sonnet' }), expected: modelB,
|
||||
tools: 'Read,Write', prompt: 'Read input.txt using Read. Write output.txt using Write with exactly the contents EZCPA_TOOL_OK:42. Then read output.txt using Read to verify it. Reply EZCPA_TOOL_OK. Do not run shell commands.', fileTools: true },
|
||||
{ id: 'subagent-inherit', mappings: mapping({}), expected: modelA, workerExpected: modelA, tools: 'Agent', prompt: workerPrompt },
|
||||
{ id: 'subagent-pinned-role', mappings: mapping({ subagentModel: 'haiku' }), expected: modelA, workerExpected: modelB, tools: 'Agent', prompt: workerPrompt },
|
||||
{ id: 'subagent-pinned-direct', mappings: mapping({ subagentModel: modelB }), expected: modelA, workerExpected: modelB, tools: 'Agent', prompt: workerPrompt },
|
||||
{ id: 'subagent-1m', mappings: mapping({ subagentModel: `${modelB}[1m]` }), expected: modelA, workerExpected: modelB, context: 1000000, tools: 'Agent', prompt: workerPrompt },
|
||||
{ id: 'unavailable-custom-model', mappings: mapping({ startupModel: 'ezcpa-live-unavailable-model' }),
|
||||
expected: 'ezcpa-live-unavailable-model', expectedError: true, prompt: smokePrompt },
|
||||
];
|
||||
const filters = process.argv.slice(2);
|
||||
const selected = cases.filter(c => !filters.length || filters.includes(c.id));
|
||||
assert(selected.length, 'No matching live cases');
|
||||
const requestPath = path.join(output, 'fixture-request.json');
|
||||
const configPath = path.join(output, 'generated-settings.json');
|
||||
fs.writeFileSync(requestPath, JSON.stringify({ baseUrl, models: [
|
||||
...[modelA, modelB].map(name => ({ name, alias: null, contextWindow: 200000 })),
|
||||
{ name: modelAlias, alias: modelA, isAlias: true, contextWindow: 200000 },
|
||||
], cases }, null, 2));
|
||||
const cargo = await runProcess('cargo', ['test', '--manifest-path', 'src-tauri/Cargo.toml',
|
||||
'export_claude_code_live_configurations', '--', '--ignored', '--nocapture'], {
|
||||
cwd: root, env: { ...process.env, EZCPA_CLAUDE_LIVE_REQUEST: requestPath, EZCPA_CLAUDE_LIVE_OUTPUT: configPath }, timeout: 300000,
|
||||
});
|
||||
fs.writeFileSync(path.join(output, 'cargo.txt'), cargo.stdout + cargo.stderr);
|
||||
assert.equal(cargo.code, 0, 'Rust fixture export failed; see cargo.txt');
|
||||
const configurations = JSON.parse(fs.readFileSync(configPath));
|
||||
const results = [];
|
||||
for (const test of selected) {
|
||||
activeCase = test.id;
|
||||
const caseDir = path.join(output, test.id);
|
||||
fs.mkdirSync(caseDir, { recursive: true });
|
||||
const configDir = path.join(caseDir, 'claude-home');
|
||||
fs.mkdirSync(configDir);
|
||||
const settings = configurations.find(c => c.id === test.id).settings;
|
||||
const settingsPath = path.join(configDir, 'settings.json');
|
||||
fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2));
|
||||
fs.writeFileSync(path.join(caseDir, 'input.txt'), 'The test number is 42.');
|
||||
const env = { ...process.env };
|
||||
for (const key of Object.keys(env)) if (/^(ANTHROPIC_|CLAUDE_|EASYCLIPROXY_MANAGE_CLAUDE|DISABLE_AUTO_COMPACT)/.test(key)) delete env[key];
|
||||
Object.assign(env, { CLAUDE_CONFIG_DIR: configDir, CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC: '1',
|
||||
DISABLE_AUTOUPDATER: '1', DISABLE_TELEMETRY: '1', DISABLE_ERROR_REPORTING: '1' });
|
||||
const args = ['-p', test.prompt, '--output-format', 'stream-json', '--verbose', '--no-session-persistence',
|
||||
'--setting-sources', '', '--settings', settingsPath, '--strict-mcp-config', '--mcp-config', '{"mcpServers":{}}',
|
||||
'--disable-slash-commands', '--tools', test.tools || '', '--permission-mode', 'acceptEdits', ...(test.args || [])];
|
||||
if (test.tools) args.push('--allowedTools', test.tools);
|
||||
if (test.debug) args.push('--debug-file', path.join(caseDir, 'debug.log'));
|
||||
if (test.workerExpected) args.push('--agents', JSON.stringify({ 'live-worker': {
|
||||
description: 'A worker for the live configuration test.', prompt: 'Reply with exactly EZCPA_WORKER_OK. Do not use tools.', tools: [],
|
||||
} }));
|
||||
console.log(`RUN ${test.id}`);
|
||||
const started = Date.now();
|
||||
const run = await runProcess(executable, args, { cwd: caseDir, env });
|
||||
fs.writeFileSync(path.join(caseDir, 'stdout.jsonl'), run.stdout);
|
||||
fs.writeFileSync(path.join(caseDir, 'stderr.txt'), run.stderr);
|
||||
const events = run.stdout.split(/\r?\n/).filter(Boolean).flatMap(line => { try { return [JSON.parse(line)]; } catch { return []; } });
|
||||
const final = events.findLast(event => event.type === 'result');
|
||||
const requests = observations.filter(request => request.case === test.id);
|
||||
const toolUses = events.flatMap(event => (event.message?.content || []).filter(block => block.type === 'tool_use').map(block => ({ name: block.name, parent: event.parent_tool_use_id || null })));
|
||||
const failures = [];
|
||||
const check = (condition, reason) => { if (!condition) failures.push(reason); };
|
||||
const autoCompactionChecks = test.debug && fs.existsSync(path.join(caseDir, 'debug.log'))
|
||||
? fs.readFileSync(path.join(caseDir, 'debug.log'), 'utf8').split(/\r?\n/).filter(line => line.includes('autocompact:')) : [];
|
||||
if (test.expectedError) {
|
||||
check(!run.timedOut && (run.code !== 0 || final?.is_error), 'Unavailable model did not report an error');
|
||||
check(requests.every(request => request.model === test.expected), 'Unavailable model silently fell back to another model');
|
||||
} else {
|
||||
check(run.code === 0 && !run.timedOut, `Claude exit ${run.code}, timedOut=${run.timedOut}`);
|
||||
check(final && !final.is_error, 'Claude did not complete successfully');
|
||||
if (test.prompt === smokePrompt) check(final?.result?.trim() === 'EZCPA_LIVE_OK', 'Smoke response marker is incorrect');
|
||||
check((final?.permission_denials || []).length === 0, 'Tool permission was denied');
|
||||
}
|
||||
const mainRequests = requests.filter(request => request.model?.replace(/\[1m\]$/i, '') === test.expected);
|
||||
check(mainRequests.length > 0, `No actual request for expected model ${test.expected}`);
|
||||
if (!test.expectedError) check(requests.every(request => request.status === 200), 'At least one actual request failed');
|
||||
if (test.context) check(Object.values(final?.modelUsage || {}).some(usage => usage.contextWindow === test.context), `Expected runtime context window ${test.context}`);
|
||||
if (test.debug) {
|
||||
check(settings.env.CLAUDE_AUTOCOMPACT_PCT_OVERRIDE === String(test.mappings.autoCompactPct), 'Compaction percentage was not persisted');
|
||||
check(test.mappings.disableAutoCompact ? autoCompactionChecks.length === 0 : autoCompactionChecks.length > 0,
|
||||
'Runtime auto-compaction check did not match the configured enabled state');
|
||||
}
|
||||
if (test.fileTools) {
|
||||
check(toolUses.some(tool => tool.name === 'Read') && toolUses.some(tool => tool.name === 'Write'), 'Read/Write tools were not both executed');
|
||||
const written = path.join(caseDir, 'output.txt');
|
||||
check(fs.existsSync(written) && fs.readFileSync(written, 'utf8').trim() === 'EZCPA_TOOL_OK:42', 'Agent output file is incorrect');
|
||||
}
|
||||
if (test.workerExpected) {
|
||||
check(toolUses.some(tool => tool.name === 'Agent'), 'Agent delegation did not execute');
|
||||
check(events.some(event => event.message?.content?.some(block => block.type === 'tool_result'
|
||||
&& JSON.stringify(block.content).includes('EZCPA_WORKER_OK') && JSON.stringify(block.content).includes('agentId:'))), 'No completed real subagent report');
|
||||
check(requests.some(request => request.toolNames.length === 0 && request.model?.replace(/\[1m\]$/i, '') === test.workerExpected), `No worker request for ${test.workerExpected}`);
|
||||
if (test.workerExpected === test.expected) check(requests.length >= 3, 'Expected parent, worker and parent continuation requests');
|
||||
}
|
||||
const result = { id: test.id, passed: failures.length === 0, failures, durationMs: Date.now() - started,
|
||||
initialModel: events.find(event => event.type === 'system' && event.subtype === 'init')?.model,
|
||||
modelUsage: final?.modelUsage, result: final?.result, toolUses, requests, autoCompactionChecks };
|
||||
fs.writeFileSync(path.join(caseDir, 'summary.json'), JSON.stringify(result, null, 2));
|
||||
results.push(result);
|
||||
fs.writeFileSync(path.join(output, 'summary.json'), JSON.stringify({ version: version.stdout.trim(), results }, null, 2));
|
||||
console.log(`${result.passed ? 'PASS' : 'FAIL'} ${test.id}: ${failures.join('; ') || requests.map(r => r.model).join(', ')}`);
|
||||
}
|
||||
for (const [protectedPath, hash] of protectedHashes) assert.equal(digest(fs.readFileSync(protectedPath)), hash, `User configuration changed: ${protectedPath}`);
|
||||
console.log(`User settings and credentials unchanged. ${results.filter(r => r.passed).length}/${results.length} live cases passed.`);
|
||||
if (results.some(result => !result.passed)) process.exitCode = 1;
|
||||
})().catch(error => { console.error(redact(error.stack)); process.exitCode = 1; }).finally(() => observer.close());
|
||||
@@ -2,17 +2,24 @@ const { chromium } = require(process.env.PLAYWRIGHT_MODULE || 'playwright');
|
||||
const assert = require('node:assert/strict');
|
||||
const path = require('node:path');
|
||||
const os = require('node:os');
|
||||
const base = process.env.AGENT_MODEL_INPUTS_TEST_URL || 'http://127.0.0.1:1421';
|
||||
|
||||
(async () => {
|
||||
const browser = await chromium.launch({ channel: 'msedge', headless: true });
|
||||
try {
|
||||
const page = await browser.newPage({ viewport: { width: 1280, height: 900 } });
|
||||
await page.goto('http://127.0.0.1:1421/tests/fixtures/agent-backups.html?client=claude-code&reset-selections');
|
||||
page.setDefaultTimeout(10000);
|
||||
await page.goto(`${base}/tests/fixtures/agent-backups.html?client=claude-code&reset-selections`);
|
||||
const startup = page.getByRole('combobox', { name: '默认模型', exact: true });
|
||||
const subagent = page.getByRole('combobox', { name: 'Subagent 模型', exact: true });
|
||||
await startup.click();
|
||||
await page.getByRole('option', { name: /^sonnet / }).click();
|
||||
assert.equal(await startup.inputValue(), 'sonnet');
|
||||
assert(await page.getByRole('button', { name: '更新配置', exact: true }).isDisabled());
|
||||
for (const role of ['opus', 'haiku', 'fable']) {
|
||||
await page.locator(`.claude-role-row[data-role=${role}] .agent-model-trigger`).click();
|
||||
await page.getByRole('option', { name: /^gpt-one / }).click();
|
||||
}
|
||||
await page.locator('.claude-role-row .agent-model-trigger').first().click();
|
||||
await page.getByRole('option', { name: /gpt-two/ }).click();
|
||||
assert.equal(await page.locator('.claude-code-model-summary').count(), 0);
|
||||
|
||||
Reference in new issue
Block a user