Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
74 changes: 69 additions & 5 deletions apps/desktop-tauri/src-tauri/src/commands/providers.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand All @@ -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 {
Expand All @@ -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 {
Expand Down
62 changes: 62 additions & 0 deletions apps/desktop-tauri/src-tauri/src/commands/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
71 changes: 70 additions & 1 deletion rust/src/providers/claude/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<std::path::PathBuf, ProviderError> {
let base = dirs::data_local_dir()
.or_else(dirs::home_dir)
Expand All @@ -100,6 +102,48 @@ fn claude_usage_probe_dir() -> Result<std::path::PathBuf, ProviderError> {
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<String> {
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,
Expand Down Expand Up @@ -249,6 +293,8 @@ async fn run_claude_pty_probe(
probe: ClaudePtyProbeOptions,
) -> Result<String, ProviderError> {
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()
Expand All @@ -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);
}
Expand Down Expand Up @@ -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();
Expand Down
Loading