From 3dbe7256bdde726dbb1881f2d1bed99c26bb176f Mon Sep 17 00:00:00 2001 From: NessZerra Date: Tue, 21 Jul 2026 08:52:49 +0700 Subject: [PATCH] fix(claude): last-good on CLI parse fail and reuse probe session id --- .../src-tauri/src/commands/providers.rs | 74 +++++++++++++++++-- .../src-tauri/src/commands/tests.rs | 62 ++++++++++++++++ rust/src/providers/claude/mod.rs | 71 +++++++++++++++++- 3 files changed, 201 insertions(+), 6 deletions(-) diff --git a/apps/desktop-tauri/src-tauri/src/commands/providers.rs b/apps/desktop-tauri/src-tauri/src/commands/providers.rs index f813d8874f..098ea52710 100644 --- a/apps/desktop-tauri/src-tauri/src/commands/providers.rs +++ b/apps/desktop-tauri/src-tauri/src/commands/providers.rs @@ -392,7 +392,23 @@ pub(super) fn preserve_last_good_transient_failure( return snapshot; } - if id != ProviderId::Claude || !is_transient_claude_auth_error(snapshot.error.as_deref()) { + if id != ProviderId::Claude { + guard.transient_provider_failure_counts.remove(&id); + return snapshot; + } + + let error = snapshot.error.as_deref(); + // Hard auth loss / subscription-unavailable answers should not keep stale bars. + if is_hard_claude_auth_loss(error) { + guard.transient_provider_failure_counts.remove(&id); + return snapshot; + } + + let preservable = is_transient_claude_auth_error(error) + || is_claude_cli_usage_parse_failure(error) + || is_claude_cli_rate_limit_failure(error) + || is_claude_timeout_failure(error); + if !preservable { guard.transient_provider_failure_counts.remove(&id); return snapshot; } @@ -406,15 +422,24 @@ pub(super) fn preserve_last_good_transient_failure( return snapshot; }; + // Parse / rate-limit / timeout: keep last-good every time (upstream #2247). + // Transient auth (unauthorized-ish) still only preserves once so real logout surfaces. + let parse_or_rate = is_claude_cli_usage_parse_failure(error) + || is_claude_cli_rate_limit_failure(error) + || is_claude_timeout_failure(error); + let count = guard .transient_provider_failure_counts .entry(id) .or_insert(0); - if *count == 0 { - *count = 1; + if parse_or_rate || *count == 0 { + if !parse_or_rate { + *count = 1; + } tracing::warn!( provider = id.cli_name(), - "preserving last good provider snapshot after transient auth failure" + error = error.unwrap_or(""), + "preserving last good Claude snapshot after transient failure" ); previous } else { @@ -431,7 +456,46 @@ fn is_transient_claude_auth_error(error: Option<&str>) -> bool { lower.contains("unauthorized") || lower.contains("authentication required") || lower.contains("auth required") - || lower.contains("oauth") +} + +fn is_hard_claude_auth_loss(error: Option<&str>) -> bool { + let Some(error) = error else { + return false; + }; + let lower = error.to_ascii_lowercase(); + // Credentials truly missing / login required — clear stale usage. + lower.contains("credentials not found") + || lower.contains("run `claude` to authenticate") + || (lower.contains("not installed") && lower.contains("claude")) + || (lower.contains("subscription") && lower.contains("unavailable")) +} + +fn is_claude_cli_usage_parse_failure(error: Option<&str>) -> bool { + let Some(error) = error else { + return false; + }; + let lower = error.to_ascii_lowercase(); + lower.contains("parse error") + || lower.contains("empty output") + || lower.contains("missing current session") + || lower.contains("treated /usage as a normal prompt") + || lower.contains("local activity stats") + || lower.contains("could not parse") +} + +fn is_claude_cli_rate_limit_failure(error: Option<&str>) -> bool { + let Some(error) = error else { + return false; + }; + let lower = error.to_ascii_lowercase(); + lower.contains("rate limit") || lower.contains("rate_limit") || lower.contains("ratelimited") +} + +fn is_claude_timeout_failure(error: Option<&str>) -> bool { + let Some(error) = error else { + return false; + }; + error.eq_ignore_ascii_case("timeout") || error.to_ascii_lowercase().contains("timed out") } async fn fetch_provider_snapshot(id: ProviderId, ctx: FetchContext) -> ProviderUsageSnapshot { diff --git a/apps/desktop-tauri/src-tauri/src/commands/tests.rs b/apps/desktop-tauri/src-tauri/src/commands/tests.rs index ae7588da53..b1e2877f4c 100644 --- a/apps/desktop-tauri/src-tauri/src/commands/tests.rs +++ b/apps/desktop-tauri/src-tauri/src/commands/tests.rs @@ -856,6 +856,68 @@ fn claude_repeated_auth_failure_surfaces_error() { assert!(surfaced.error.is_some()); } +#[test] +fn claude_cli_parse_failure_keeps_last_good_every_time() { + let metadata = instantiate_provider(ProviderId::Claude).metadata().clone(); + let result = ProviderFetchResult { + usage: codexbar::core::UsageSnapshot::new(codexbar::core::RateWindow::new(17.0)), + cost: None, + wayfinder_usage: None, + source_label: "CLI".to_string(), + }; + let good = ProviderUsageSnapshot::from_fetch_result(ProviderId::Claude, &metadata, &result); + let err = ProviderUsageSnapshot::from_error( + ProviderId::Claude, + &metadata, + "Parse error: Empty output from Claude CLI".to_string(), + ); + let mut state = crate::state::AppState::new(); + state.provider_cache.push(good.clone()); + + let first = super::providers::preserve_last_good_transient_failure( + &mut state, + ProviderId::Claude, + err.clone(), + ); + let second = super::providers::preserve_last_good_transient_failure( + &mut state, + ProviderId::Claude, + err, + ); + + assert_eq!(first.error, None); + assert_eq!(first.primary.used_percent, 17.0); + // Parse failures keep last-good on every refresh (upstream #2247), unlike one-shot auth. + assert_eq!(second.error, None); + assert_eq!(second.primary.used_percent, 17.0); +} + +#[test] +fn claude_hard_credentials_missing_does_not_preserve_stale() { + let metadata = instantiate_provider(ProviderId::Claude).metadata().clone(); + let result = ProviderFetchResult { + usage: codexbar::core::UsageSnapshot::new(codexbar::core::RateWindow::new(17.0)), + cost: None, + wayfinder_usage: None, + source_label: "OAuth".to_string(), + }; + let good = ProviderUsageSnapshot::from_fetch_result(ProviderId::Claude, &metadata, &result); + let err = ProviderUsageSnapshot::from_error( + ProviderId::Claude, + &metadata, + "OAuth error: Claude OAuth credentials not found. Run `claude` to authenticate.".to_string(), + ); + let mut state = crate::state::AppState::new(); + state.provider_cache.push(good); + + let out = super::providers::preserve_last_good_transient_failure( + &mut state, + ProviderId::Claude, + err, + ); + assert!(out.error.is_some()); +} + #[test] fn claude_error_message_removes_upstream_swift_cancellation() { let message = super::friendly_provider_error( diff --git a/rust/src/providers/claude/mod.rs b/rust/src/providers/claude/mod.rs index deb2e1ce5e..7dcec36c5b 100755 --- a/rust/src/providers/claude/mod.rs +++ b/rust/src/providers/claude/mod.rs @@ -84,6 +84,8 @@ fn claude_plan_label(tier: &str) -> String { } } +const CLAUDE_PROBE_SESSION_ID_FILE: &str = ".codexbar-session-id"; + fn claude_usage_probe_dir() -> Result { let base = dirs::data_local_dir() .or_else(dirs::home_dir) @@ -100,6 +102,48 @@ fn claude_usage_probe_dir() -> Result { Ok(dir) } +/// Persist and reuse one probe session id so repeated `/usage` PTY launches do +/// not register a fresh empty Claude account session each refresh (upstream #2263). +fn load_or_create_probe_session_id(probe_dir: &std::path::Path) -> String { + let path = probe_dir.join(CLAUDE_PROBE_SESSION_ID_FILE); + if let Ok(raw) = std::fs::read_to_string(&path) { + let trimmed = raw.trim(); + if uuid::Uuid::parse_str(trimmed).is_ok() { + return trimmed.to_ascii_lowercase(); + } + } + let id = uuid::Uuid::new_v4().to_string().to_ascii_lowercase(); + if let Err(err) = std::fs::write(&path, &id) { + tracing::debug!(error = %err, "failed to persist Claude probe session id"); + } + id +} + +/// Claude treats `--session-id` as create-only when a local transcript JSONL +/// already exists for that id. Clear probe-dir jsonl leftovers before reuse. +fn cleanup_probe_session_jsonl(probe_dir: &std::path::Path) { + let Ok(entries) = std::fs::read_dir(probe_dir) else { + return; + }; + for entry in entries.flatten() { + let path = entry.path(); + if path.extension().and_then(|e| e.to_str()) == Some("jsonl") { + let _ = std::fs::remove_file(&path); + } + } +} + +fn claude_probe_launch_args(session_id: &str) -> Vec { + vec![ + "--setting-sources".to_string(), + "user".to_string(), + "--allowed-tools".to_string(), + String::new(), + "--session-id".to_string(), + session_id.to_string(), + ] +} + struct ClaudePtyProbeOptions { script: &'static str, timeout_secs: f64, @@ -249,6 +293,8 @@ async fn run_claude_pty_probe( probe: ClaudePtyProbeOptions, ) -> Result { tokio::task::spawn_blocking(move || { + cleanup_probe_session_jsonl(&working_directory); + let session_id = load_or_create_probe_session_id(&working_directory); let env = claude_passive_probe_env(TtyCommandRunner::enriched_environment()); let mut options = TtyCommandOptions::new() @@ -257,7 +303,7 @@ async fn run_claude_pty_probe( .with_script_char_delay(probe.script_char_delay_secs) .with_script_line_delay(probe.script_line_delay_secs) .with_working_directory(working_directory) - .with_extra_args(vec!["--setting-sources".to_string(), "user".to_string()]); + .with_extra_args(claude_probe_launch_args(&session_id)); if let Some(idle) = probe.idle_timeout_secs { options = options.with_idle_timeout(idle); } @@ -933,6 +979,29 @@ mod tests { assert_eq!(env.get("NO_COLOR").map(String::as_str), Some("1")); } + #[test] + fn probe_session_id_is_reused_from_probe_directory() { + let dir = tempfile::tempdir().unwrap(); + let first = load_or_create_probe_session_id(dir.path()); + let second = load_or_create_probe_session_id(dir.path()); + assert_eq!(first, second); + assert!(uuid::Uuid::parse_str(&first).is_ok()); + let args = claude_probe_launch_args(&first); + assert!(args.windows(2).any(|w| w[0] == "--session-id" && w[1] == first)); + assert!(args.windows(2).any(|w| w[0] == "--allowed-tools" && w[1].is_empty())); + } + + #[test] + fn probe_session_jsonl_cleanup_removes_transcript_files() { + let dir = tempfile::tempdir().unwrap(); + let jsonl = dir.path().join("session.jsonl"); + std::fs::write(&jsonl, "{}").unwrap(); + std::fs::write(dir.path().join("keep.txt"), "x").unwrap(); + cleanup_probe_session_jsonl(dir.path()); + assert!(!jsonl.exists()); + assert!(dir.path().join("keep.txt").exists()); + } + #[test] fn parses_current_cli_usage_screen() { let provider = ClaudeProvider::new();