diff --git a/CHANGELOG.md b/CHANGELOG.md index 615254d5..e576f767 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -47,14 +47,22 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 `POST /messages` requests on stale legacy SSE sessions now trigger the same reconnect-and-retry path as closed SSE streams, removing a release-gate flake and matching the intended recovery behavior (#2597). +- **Cache-hit cost accounting uses one telemetry source.** Mixed DeepSeek + `prompt_cache_hit_tokens` and OpenAI-style `cached_tokens` usage payloads no + longer infer cache misses from the wrong hit count, avoiding inflated TUI cost + estimates on cached DeepSeek turns (#2567, #2609). +- **Cygwin/MSYS2 config paths honor exported `$HOME`.** CodeWhale and legacy + DeepSeek config roots now prefer a non-empty `$HOME` before falling back to the + platform home resolver, while `CODEWHALE_HOME` remains the strongest explicit + override (#2369, #2610). ### Community Thanks to **@xyuai** (#2587), **@IcedOranges** (#2584), **@BH8GCJ** (#2588), **@shenjackyuanjie** (#2618, #2619), **@idling11** (#2606, #2616), -**@AresNing** (#2578), **@gordonlu**, **@encyc**, and **@simuusang** (#2603, -#2620) for reports, patches, retesting, and release-stabilization signals that -shaped this pass. +**@AresNing** (#2578), **@caiyilian** (#2567), **@buko** (#2369), +**@gordonlu**, **@encyc**, and **@simuusang** (#2603, #2620) for reports, +patches, retesting, and release-stabilization signals that shaped this pass. ## [0.8.51] - 2026-06-02 diff --git a/crates/config/src/lib.rs b/crates/config/src/lib.rs index 1c79e62f..8e52f03c 100644 --- a/crates/config/src/lib.rs +++ b/crates/config/src/lib.rs @@ -2038,7 +2038,7 @@ pub fn codewhale_home() -> Result { return Ok(PathBuf::from(trimmed)); } } - let home = dirs::home_dir().context("failed to resolve home directory")?; + let home = effective_home_dir().context("failed to resolve home directory")?; Ok(home.join(CODEWHALE_APP_DIR)) } @@ -2046,10 +2046,17 @@ pub fn codewhale_home() -> Result { /// /// Always returns the legacy path regardless of whether it exists. pub fn legacy_deepseek_home() -> Result { - let home = dirs::home_dir().context("failed to resolve home directory")?; + let home = effective_home_dir().context("failed to resolve home directory")?; Ok(home.join(LEGACY_APP_DIR)) } +fn effective_home_dir() -> Option { + std::env::var_os("HOME") + .filter(|value| !value.is_empty()) + .map(PathBuf::from) + .or_else(dirs::home_dir) +} + /// Resolve a state subdirectory, preferring the CodeWhale root if /// it already exists, otherwise falling back to the legacy root. /// @@ -2507,7 +2514,9 @@ mod tests { fn env_lock() -> std::sync::MutexGuard<'static, ()> { static LOCK: OnceLock> = OnceLock::new(); - LOCK.get_or_init(|| Mutex::new(())).lock().unwrap() + LOCK.get_or_init(|| Mutex::new(())) + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) } #[test] @@ -3485,6 +3494,73 @@ unix_socket_path = "/tmp/cw-hooks.sock" ); } + #[test] + fn app_homes_prefer_home_env_before_platform_home_fallback() { + let _lock = env_lock(); + struct HomeEnvGuard { + home: Option, + userprofile: Option, + codewhale_home: Option, + } + + impl Drop for HomeEnvGuard { + fn drop(&mut self) { + // Safety: test-only environment mutation is serialized by env_lock(). + unsafe { + match self.home.take() { + Some(value) => env::set_var("HOME", value), + None => env::remove_var("HOME"), + } + match self.userprofile.take() { + Some(value) => env::set_var("USERPROFILE", value), + None => env::remove_var("USERPROFILE"), + } + match self.codewhale_home.take() { + Some(value) => env::set_var("CODEWHALE_HOME", value), + None => env::remove_var("CODEWHALE_HOME"), + } + } + } + } + + let home = + std::env::temp_dir().join(format!("codewhale-config-home-env-{}", std::process::id())); + let userprofile = std::env::temp_dir().join(format!( + "codewhale-config-userprofile-{}", + std::process::id() + )); + let _env = HomeEnvGuard { + home: env::var_os("HOME"), + userprofile: env::var_os("USERPROFILE"), + codewhale_home: env::var_os("CODEWHALE_HOME"), + }; + // Safety: test-only environment mutation is serialized by env_lock(). + unsafe { + env::set_var("HOME", &home); + env::set_var("USERPROFILE", &userprofile); + env::remove_var("CODEWHALE_HOME"); + } + + assert_eq!( + codewhale_home().expect("codewhale home"), + home.join(CODEWHALE_APP_DIR) + ); + assert_eq!( + legacy_deepseek_home().expect("legacy home"), + home.join(LEGACY_APP_DIR) + ); + + let explicit = std::env::temp_dir().join(format!( + "codewhale-config-explicit-home-{}", + std::process::id() + )); + // Safety: test-only environment mutation is serialized by env_lock(). + unsafe { + env::set_var("CODEWHALE_HOME", &explicit); + } + assert_eq!(codewhale_home().expect("explicit home"), explicit); + } + #[test] fn migrate_config_reports_copied_legacy_path() { let _lock = env_lock(); @@ -3550,9 +3626,6 @@ unix_socket_path = "/tmp/cw-hooks.sock" "codewhale-config-migration-{}-{unique}", std::process::id() )); - #[cfg(windows)] - let legacy_dir = legacy_deepseek_home().expect("legacy home"); - #[cfg(not(windows))] let legacy_dir = home.join(LEGACY_APP_DIR); let primary_dir = home.join(CODEWHALE_APP_DIR); let legacy_config = legacy_dir.join(CONFIG_FILE_NAME); diff --git a/crates/tui/CHANGELOG.md b/crates/tui/CHANGELOG.md index 615254d5..e576f767 100644 --- a/crates/tui/CHANGELOG.md +++ b/crates/tui/CHANGELOG.md @@ -47,14 +47,22 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 `POST /messages` requests on stale legacy SSE sessions now trigger the same reconnect-and-retry path as closed SSE streams, removing a release-gate flake and matching the intended recovery behavior (#2597). +- **Cache-hit cost accounting uses one telemetry source.** Mixed DeepSeek + `prompt_cache_hit_tokens` and OpenAI-style `cached_tokens` usage payloads no + longer infer cache misses from the wrong hit count, avoiding inflated TUI cost + estimates on cached DeepSeek turns (#2567, #2609). +- **Cygwin/MSYS2 config paths honor exported `$HOME`.** CodeWhale and legacy + DeepSeek config roots now prefer a non-empty `$HOME` before falling back to the + platform home resolver, while `CODEWHALE_HOME` remains the strongest explicit + override (#2369, #2610). ### Community Thanks to **@xyuai** (#2587), **@IcedOranges** (#2584), **@BH8GCJ** (#2588), **@shenjackyuanjie** (#2618, #2619), **@idling11** (#2606, #2616), -**@AresNing** (#2578), **@gordonlu**, **@encyc**, and **@simuusang** (#2603, -#2620) for reports, patches, retesting, and release-stabilization signals that -shaped this pass. +**@AresNing** (#2578), **@caiyilian** (#2567), **@buko** (#2369), +**@gordonlu**, **@encyc**, and **@simuusang** (#2603, #2620) for reports, +patches, retesting, and release-stabilization signals that shaped this pass. ## [0.8.51] - 2026-06-02 diff --git a/crates/tui/src/client.rs b/crates/tui/src/client.rs index 2ca4fd28..25c92188 100644 --- a/crates/tui/src/client.rs +++ b/crates/tui/src/client.rs @@ -1275,7 +1275,7 @@ pub(super) fn parse_usage(usage: Option<&Value>) -> Usage { let prompt_cache_miss_tokens = usage .and_then(|u| u.get("prompt_cache_miss_tokens")) .and_then(Value::as_u64) - .or_else(|| cached_tokens.map(|cached| input_tokens.saturating_sub(cached))) + .or_else(|| prompt_cache_hit_tokens.map(|hit| input_tokens.saturating_sub(u64::from(hit)))) .map(|v| v as u32); let reasoning_tokens = reasoning_tokens_raw.map(|v| v as u32); @@ -3078,6 +3078,22 @@ mod tests { assert_eq!(usage.prompt_cache_miss_tokens, Some(1000)); } + #[test] + fn parse_usage_infers_cache_miss_from_selected_hit_source() { + let usage = parse_usage(Some(&json!({ + "prompt_tokens": 4000, + "completion_tokens": 20, + "prompt_cache_hit_tokens": 3000, + "prompt_tokens_details": { + "cached_tokens": 1000 + } + }))); + + assert_eq!(usage.input_tokens, 4000); + assert_eq!(usage.prompt_cache_hit_tokens, Some(3000)); + assert_eq!(usage.prompt_cache_miss_tokens, Some(1000)); + } + #[test] fn sanitize_thinking_mode_counts_reasoning_replay_across_assistant_turns() { // Multi-turn body that mimics two prior tool-calling rounds: each diff --git a/crates/tui/src/pricing.rs b/crates/tui/src/pricing.rs index 18f75af2..9ac95890 100644 --- a/crates/tui/src/pricing.rs +++ b/crates/tui/src/pricing.rs @@ -161,6 +161,10 @@ pub fn calculate_turn_cost(model: &str, input_tokens: u32, output_tokens: u32) - } /// Calculate cost for a turn in both official currencies. +/// +/// This legacy helper has no cache telemetry, so it prices all input tokens as +/// cache misses. Prefer [`calculate_turn_cost_estimate_from_usage`] when the +/// provider returned usage details. #[must_use] pub fn calculate_turn_cost_estimate( model: &str, diff --git a/crates/tui/src/tools/subagent/tests.rs b/crates/tui/src/tools/subagent/tests.rs index 2731870a..3ec14a46 100644 --- a/crates/tui/src/tools/subagent/tests.rs +++ b/crates/tui/src/tools/subagent/tests.rs @@ -965,7 +965,7 @@ async fn test_running_count_counts_running_agents_until_status_reconciles() { #[tokio::test] async fn cleanup_auto_cancels_stale_running_agent_and_releases_slot() { let mut manager = SubAgentManager::new(PathBuf::from("."), 1) - .with_running_heartbeat_timeout(Duration::from_secs(300)); + .with_running_heartbeat_timeout(Duration::from_millis(1)); let (input_tx, _input_rx) = mpsc::unbounded_channel(); let mut agent = SubAgent::new( "test_agent_stale".to_string(), @@ -978,12 +978,12 @@ async fn cleanup_auto_cancels_stale_running_agent_and_releases_slot() { input_tx, "boot_test".to_string(), ); - agent.last_activity_at = instant_from_duration(Duration::from_secs(600)); agent.task_handle = Some(tokio::spawn(async { tokio::time::sleep(Duration::from_secs(60)).await; })); let agent_id = agent.id.clone(); manager.agents.insert(agent_id.clone(), agent); + tokio::time::sleep(Duration::from_millis(5)).await; assert_eq!( manager.running_count(), @@ -1046,7 +1046,7 @@ async fn cleanup_keeps_recent_running_agent() { #[tokio::test] async fn touch_refreshes_stale_running_agent_heartbeat() { let mut manager = SubAgentManager::new(PathBuf::from("."), 1) - .with_running_heartbeat_timeout(Duration::from_secs(300)); + .with_running_heartbeat_timeout(Duration::from_millis(1)); let (input_tx, _input_rx) = mpsc::unbounded_channel(); let mut agent = SubAgent::new( "test_agent_touched".to_string(), @@ -1059,12 +1059,12 @@ async fn touch_refreshes_stale_running_agent_heartbeat() { input_tx, "boot_test".to_string(), ); - agent.last_activity_at = instant_from_duration(Duration::from_secs(600)); agent.task_handle = Some(tokio::spawn(async { tokio::time::sleep(Duration::from_secs(60)).await; })); let agent_id = agent.id.clone(); manager.agents.insert(agent_id.clone(), agent); + tokio::time::sleep(Duration::from_millis(5)).await; assert_eq!(manager.running_count(), 0); assert!(manager.touch(&agent_id));