Skip to content
Closed
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
15 changes: 15 additions & 0 deletions Sources/CodexBarCore/Providers/Claude/ClaudeStatusProbe.swift
Original file line number Diff line number Diff line change
Expand Up @@ -158,6 +158,15 @@ public struct ClaudeStatusProbe: Sendable {
throw ClaudeStatusProbeError.parseFailed(usageError)
}

if self.isUsageStillLoading(text: clean) {
Self.dumpIfNeeded(
enabled: shouldDump,
reason: "usage still loading",
usage: clean,
status: statusText)
throw ClaudeStatusProbeError.parseFailed("Claude CLI /usage is still loading usage data.")
}

// Claude CLI renders /usage as a TUI. Our PTY capture includes earlier screen fragments (including a status
// line
// with a "0%" context meter) before the usage panel is drawn. To keep parsing stable, trim to the last
Expand Down Expand Up @@ -452,6 +461,12 @@ public struct ClaudeStatusProbe: Sendable {
return nil
}

private static func isUsageStillLoading(text: String) -> Bool {
let normalized = TextParsing.stripANSICodes(text).lowercased().filter { !$0.isWhitespace }
guard normalized.contains("loadingusage") else { return false }
return !self.usageCaptureHasSessionValue(normalized) && self.allPercents(text).isEmpty
}

/// Collect remaining percentages in the order they appear; used as a backup when labels move/rename.
private static func allPercents(_ text: String) -> [Int] {
let lines = text.components(separatedBy: .newlines)
Expand Down
24 changes: 20 additions & 4 deletions Sources/CodexBarCore/Providers/Claude/ClaudeUsageFetcher.swift
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,7 @@ public enum ClaudeUsageError: LocalizedError, Sendable {
public struct ClaudeUsageFetcher: ClaudeUsageFetching, Sendable {
private static let sessionWindowMinutes = 5 * 60
private static let weeklyWindowMinutes = 7 * 24 * 60
private static let cliAutoProbeTimeout: TimeInterval = 12
private static let cliProbeTimeout: TimeInterval = 24
private static let cliRetryProbeTimeout: TimeInterval = 60
private struct Configuration {
Expand Down Expand Up @@ -476,10 +477,11 @@ public struct ClaudeUsageFetcher: ClaudeUsageFetching, Sendable {

let executionSteps = plan.executionSteps
for (index, step) in executionSteps.enumerated() {
let isFinalStep = index == executionSteps.count - 1
do {
return try await self.execute(step: step, model: model)
return try await self.execute(step: step, model: model, isFinalStep: isFinalStep)
} catch {
if index < executionSteps.count - 1 {
if !isFinalStep {
ClaudeUsageFetcher.log.debug(
"Claude planner step failed; falling back to next step",
metadata: [
Expand Down Expand Up @@ -530,7 +532,11 @@ public struct ClaudeUsageFetcher: ClaudeUsageFetching, Sendable {
ClaudeUsageFetcher.log.debug("Claude auto source planner", metadata: metadata)
}

private func execute(step: ClaudeFetchPlanStep, model: String) async throws -> ClaudeUsageSnapshot {
private func execute(
step: ClaudeFetchPlanStep,
model: String,
isFinalStep: Bool) async throws -> ClaudeUsageSnapshot
{
switch step.dataSource {
case .api:
throw ClaudeUsageError.parseFailed("Planner emitted invalid api execution step.")
Expand All @@ -541,12 +547,22 @@ public struct ClaudeUsageFetcher: ClaudeUsageFetching, Sendable {
case .web:
return try await self.fetcher.loadViaWebAPI()
case .cli:
return try await self.loadViaCLIWithRetry(model: model)
return try await self.loadViaAutoCLI(model: model, isFinalStep: isFinalStep)
case .auto:
throw ClaudeUsageError.parseFailed("Planner emitted invalid auto execution step.")
}
}

private func loadViaAutoCLI(model: String, isFinalStep: Bool) async throws -> ClaudeUsageSnapshot {
do {
return try await self.loadViaCLI(model: model, timeout: ClaudeUsageFetcher.cliAutoProbeTimeout)
} catch {
if error is CancellationError { throw error }
guard isFinalStep, Self.shouldRetryCLIProbe(after: error) else { throw error }
return try await self.loadViaCLI(model: model, timeout: ClaudeUsageFetcher.cliRetryProbeTimeout)
}
}

private func loadViaCLIWithRetry(model: String) async throws -> ClaudeUsageSnapshot {
do {
return try await self.loadViaCLI(model: model, timeout: ClaudeUsageFetcher.cliProbeTimeout)
Expand Down
95 changes: 95 additions & 0 deletions Tests/CodexBarTests/ClaudeCLITimeoutRetryTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,78 @@ struct ClaudeCLITimeoutRetryTests {
#expect(snapshot.accountEmail == "cli@example.com")
}

@Test
func `auto cli usage uses bounded timeout without long retry`() async throws {
let attempts = AttemptRecorder()
let fetcher = ClaudeUsageFetcher(
browserDetection: BrowserDetection(cacheTTL: 0),
environment: [:],
dataSource: .auto,
manualCookieHeader: "foo=bar")

let fetchOverride: ClaudeStatusProbe.FetchOverride = { _, timeout, _ in
_ = await attempts.record(timeout: timeout)
throw ClaudeStatusProbeError.parseFailed("Claude CLI /usage is still loading usage data.")
}

await #expect(throws: ClaudeStatusProbeError.self) {
try await self.withNoOAuthCredentials {
try await ClaudeCLIResolver.withResolvedBinaryPathOverrideForTesting("/usr/bin/true") {
try await ClaudeStatusProbe.withFetchOverrideForTesting(fetchOverride) {
try await fetcher.loadLatestUsage(model: "sonnet")
}
}
}
}

let recorded = await attempts.snapshot()
#expect(recorded.count == 1)
#expect(recorded.timeouts == [12])
}

@Test
func `auto cli usage retries timeout when cli is final source`() async throws {
let attempts = AttemptRecorder()
let fetcher = ClaudeUsageFetcher(
browserDetection: BrowserDetection(cacheTTL: 0),
environment: [:],
dataSource: .auto,
manualCookieHeader: "foo=bar")

let fetchOverride: ClaudeStatusProbe.FetchOverride = { _, timeout, _ in
let attempt = await attempts.record(timeout: timeout)
if attempt == 1 {
throw ClaudeStatusProbeError.timedOut
}
return ClaudeStatusSnapshot(
sessionPercentLeft: 72,
weeklyPercentLeft: 64,
opusPercentLeft: nil,
accountEmail: "auto-cli@example.com",
accountOrganization: "Auto CLI Org",
loginMethod: "cli",
primaryResetDescription: nil,
secondaryResetDescription: nil,
opusResetDescription: nil,
rawText: "probe raw")
}

let snapshot = try await self.withNoOAuthCredentials {
try await ClaudeCLIResolver.withResolvedBinaryPathOverrideForTesting("/usr/bin/true") {
try await ClaudeStatusProbe.withFetchOverrideForTesting(fetchOverride) {
try await fetcher.loadLatestUsage(model: "sonnet")
}
}
}

let recorded = await attempts.snapshot()
#expect(recorded.count == 2)
#expect(recorded.timeouts == [12, 60])
#expect(snapshot.primary.usedPercent == 28)
#expect(snapshot.secondary?.usedPercent == 36)
#expect(snapshot.accountEmail == "auto-cli@example.com")
}

@Test
func `cli usage does not retry cancelled probe`() async throws {
let attempts = AttemptRecorder()
Expand All @@ -84,4 +156,27 @@ struct ClaudeCLITimeoutRetryTests {
#expect(recorded.count == 1)
#expect(recorded.timeouts == [24])
}

private func withNoOAuthCredentials<T>(operation: () async throws -> T) async rethrows -> T {
let missingCredentialsURL = FileManager.default.temporaryDirectory
.appendingPathComponent("missing-claude-creds-\(UUID().uuidString).json")
return try await KeychainCacheStore.withServiceOverrideForTesting("rat-107-\(UUID().uuidString)") {
KeychainCacheStore.setTestStoreForTesting(true)
defer { KeychainCacheStore.setTestStoreForTesting(false) }
return try await ClaudeOAuthCredentialsStore.withIsolatedMemoryCacheForTesting {
try await ClaudeOAuthCredentialsStore.withIsolatedCredentialsFileTrackingForTesting {
try await ClaudeOAuthCredentialsStore.withCredentialsURLOverrideForTesting(missingCredentialsURL) {
try await ClaudeOAuthCredentialsStore.withKeychainAccessOverrideForTesting(true) {
try await ClaudeOAuthCredentialsStore.withClaudeKeychainOverridesForTesting(
data: nil,
fingerprint: nil)
{
try await operation()
}
}
}
}
}
}
}
}
5 changes: 3 additions & 2 deletions Tests/CodexBarTests/StatusProbeTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -288,7 +288,7 @@ struct StatusProbeTests {
}

@Test
func `parse claude status loading panel does not report zero percent`() {
func `parse claude status loading panel surfaces loading stall`() {
let sample = """
Claude Code v2.1.29
22:47 | | Opus 4.5 | default | ░░░░░░░░░░ 0% ◯ /ide for Visual Studio Code
Expand All @@ -301,7 +301,8 @@ struct StatusProbeTests {
do {
_ = try ClaudeStatusProbe.parse(text: sample)
#expect(Bool(false), "Parsing should fail while /usage is still loading")
} catch ClaudeStatusProbeError.parseFailed {
} catch let ClaudeStatusProbeError.parseFailed(message) {
#expect(message.lowercased().contains("loading"))
return
} catch ClaudeStatusProbeError.timedOut {
return
Expand Down