diff --git a/src-tauri/src/agents/configuration.rs b/src-tauri/src/agents/configuration.rs index e8ca88e..6061157 100644 --- a/src-tauri/src/agents/configuration.rs +++ b/src-tauri/src/agents/configuration.rs @@ -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::>(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::::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::>(); + 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, + original: Option<&serde_json::Map>, +) { + 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::>(); + 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::>(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, 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", diff --git a/src-tauri/src/agents/transactions.rs b/src-tauri/src/agents/transactions.rs index cb3b997..1e6c866 100644 --- a/src-tauri/src/agents/transactions.rs +++ b/src-tauri/src/agents/transactions.rs @@ -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" diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index 9b49ced..843f652 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -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, /// Optional explicit model written to CLAUDE_CODE_SUBAGENT_MODEL. diff --git a/src-tauri/src/tests.rs b/src-tauri/src/tests.rs index 2fce440..fa3ce7c 100644 --- a/src-tauri/src/tests.rs +++ b/src-tauri/src/tests.rs @@ -1,6 +1,7 @@ use super::*; mod agent_configuration; +mod claude_code_live; mod deepseek_harness_catalog; mod deepseek_desktop; mod agent_paths; diff --git a/src-tauri/src/tests/claude_code_live.rs b/src-tauri/src/tests/claude_code_live.rs new file mode 100644 index 0000000..7e30cd6 --- /dev/null +++ b/src-tauri/src/tests/claude_code_live.rs @@ -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::(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 = 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::::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(); +} diff --git a/src/services/agentConfigurationDraft.ts b/src/services/agentConfigurationDraft.ts index aea35ed..ba147f6 100644 --- a/src/services/agentConfigurationDraft.ts +++ b/src/services/agentConfigurationDraft.ts @@ -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) diff --git a/tests/agentModelPicker.test.ts b/tests/agentModelPicker.test.ts index 7b88b8a..cf7418e 100644 --- a/tests/agentModelPicker.test.ts +++ b/tests/agentModelPicker.test.ts @@ -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 = { diff --git a/tests/claude-code-live.cjs b/tests/claude-code-live.cjs new file mode 100644 index 0000000..3c5dae0 --- /dev/null +++ b/tests/claude-code-live.cjs @@ -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()); diff --git a/tests/claude-code-model-inputs-ui.cjs b/tests/claude-code-model-inputs-ui.cjs index 437dc06..f631638 100644 --- a/tests/claude-code-model-inputs-ui.cjs +++ b/tests/claude-code-model-inputs-ui.cjs @@ -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);