diff --git a/CHANGELOG.md b/CHANGELOG.md index ca4b2a3ecb..61010429a5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,7 @@ - Hide untouched Antigravity model families in the `codexbar serve` web dashboard, matching the menu and widgets (#3061). Thanks @urda! - Documented the AI Usage Limits Stream Deck plugin in the README integrations list (#3066). Thanks @lenadweb! - OpenCode Go: use the public authenticated usage API when `OPENCODE_API_KEY` is configured, overlaying authoritative rolling/weekly/monthly windows on local history with cookie fallback (#2879, #3065). Thanks @akshayprabhu200! +- Claude: keep 100% claude-swap usage bars when cswap defers polling at a limit, and name the exhausted window and reset instead of showing "Usage fetch failed." (#3081). ## 0.54.0 — 2026-08-18 diff --git a/Sources/CodexBar/Providers/Claude/UsageStore+ClaudeSwapRefresh.swift b/Sources/CodexBar/Providers/Claude/UsageStore+ClaudeSwapRefresh.swift index 6df8772f90..8bb5a669a7 100644 --- a/Sources/CodexBar/Providers/Claude/UsageStore+ClaudeSwapRefresh.swift +++ b/Sources/CodexBar/Providers/Claude/UsageStore+ClaudeSwapRefresh.swift @@ -74,10 +74,14 @@ extension UsageStore { do { let list = try await ClaudeSwapAccountReader.readAccountList(executablePath: executablePath) - let snapshots = ClaudeSwapAccountProjection.accountSnapshots(from: list) + let snapshots = ClaudeSwapAccountProjection.accountSnapshots( + from: list, + previousAccounts: ClaudeSwapRetainedUsageStore.previousAccounts( + inMemory: self.claudeSwapAccountSnapshots)) guard self.isCurrentClaudeSwapRefresh(executablePath: executablePath, generation: generation) else { return } + ClaudeSwapRetainedUsageStore.save(snapshots) self.claudeSwapAccountSnapshots = snapshots self.claudeSwapLastRefreshAt = Date() self.claudeSwapLastError = nil diff --git a/Sources/CodexBarCLI/CLIClaudeSwapCards.swift b/Sources/CodexBarCLI/CLIClaudeSwapCards.swift index cd146c0de9..257739de52 100644 --- a/Sources/CodexBarCLI/CLIClaudeSwapCards.swift +++ b/Sources/CodexBarCLI/CLIClaudeSwapCards.swift @@ -125,13 +125,19 @@ enum CLIClaudeSwapCards { showSingleAccount: Bool = false, renderOptions: CLIClaudeSwapCardsRenderOptions, ambientFetch: @escaping AmbientFetch, - accountListReader: @escaping AccountListReader) async -> UsageCommandOutput + accountListReader: @escaping AccountListReader, + previousAccounts: [ProviderAccountUsageSnapshot] = []) async -> UsageCommandOutput { guard eligible else { return await ambientFetch() } do { let list = try await accountListReader(executablePath) - let accounts = ClaudeSwapAccountProjection.accountSnapshots(from: list, now: renderOptions.now) + let retained = ClaudeSwapRetainedUsageStore.previousAccounts(inMemory: previousAccounts) + let accounts = ClaudeSwapAccountProjection.accountSnapshots( + from: list, + previousAccounts: retained, + now: renderOptions.now) + ClaudeSwapRetainedUsageStore.save(accounts) guard ClaudeSwapAccountProjection.shouldPresentAccounts( accountCount: accounts.count, showSingleAccount: showSingleAccount) diff --git a/Sources/CodexBarCLI/CLIDashboardCommand.swift b/Sources/CodexBarCLI/CLIDashboardCommand.swift index 391e1b5576..a88a9a622e 100644 --- a/Sources/CodexBarCLI/CLIDashboardCommand.swift +++ b/Sources/CodexBarCLI/CLIDashboardCommand.swift @@ -139,8 +139,12 @@ struct DashboardSnapshotProducer: Sendable { let list = try await ClaudeSwapAccountReader.readAccountList( executablePath: path, timeout: timeout) + let accounts = ClaudeSwapAccountProjection.accountSnapshots( + from: list, + previousAccounts: ClaudeSwapRetainedUsageStore.load()) + ClaudeSwapRetainedUsageStore.save(accounts) return DashboardClaudeSwapCollection( - accounts: ClaudeSwapAccountProjection.accountSnapshots(from: list), + accounts: accounts, adapterError: nil) } catch { let diagnostic = CLIClaudeSwapText.sanitizeDiagnostic(error.localizedDescription) diff --git a/Sources/CodexBarCLI/CLIServeWebUI+HTML.swift b/Sources/CodexBarCLI/CLIServeWebUI+HTML.swift index a409370cc8..a4033e0dbd 100644 --- a/Sources/CodexBarCLI/CLIServeWebUI+HTML.swift +++ b/Sources/CodexBarCLI/CLIServeWebUI+HTML.swift @@ -899,9 +899,10 @@ extension CLIServeWebUI { card.append(identity); } + // At-limit claude-swap cards carry both a deferred/limit note and retained + // windows; keep those bars visible instead of returning after the note. if (account.error) { card.append(node("p", "error-message", account.error)); - return card; } const windows = node("div", "windows"); diff --git a/Sources/CodexBarCore/Providers/Claude/ClaudeSwap/ClaudeSwapAccountProjection.swift b/Sources/CodexBarCore/Providers/Claude/ClaudeSwap/ClaudeSwapAccountProjection.swift index 439caebe07..ef82028542 100644 --- a/Sources/CodexBarCore/Providers/Claude/ClaudeSwap/ClaudeSwapAccountProjection.swift +++ b/Sources/CodexBarCore/Providers/Claude/ClaudeSwap/ClaudeSwapAccountProjection.swift @@ -8,6 +8,8 @@ public enum ClaudeSwapAccountProjection { public static let sourceLabel = "claude-swap" static let fiveHourWindowMinutes = 5 * 60 static let sevenDayWindowMinutes = 7 * 24 * 60 + static let exhaustedUsedPercent = 100.0 + static let deferredPollingNote = "Polling deferred until a limit resets." public static func shouldPresentAccounts(accountCount: Int, showSingleAccount: Bool) -> Bool { accountCount >= (showSingleAccount ? 1 : 2) @@ -15,8 +17,12 @@ public enum ClaudeSwapAccountProjection { public static func accountSnapshots( from list: ClaudeSwapAccountList, + previousAccounts: [ProviderAccountUsageSnapshot] = [], now: Date = Date()) -> [ProviderAccountUsageSnapshot] { + let previousByID = Dictionary( + previousAccounts.map { ($0.id, $0) }, + uniquingKeysWith: { first, _ in first }) let ordered = list.accounts.sorted { lhs, rhs in if lhs.isActive != rhs.isActive { return lhs.isActive @@ -24,14 +30,19 @@ public enum ClaudeSwapAccountProjection { return lhs.number < rhs.number } return ordered.map { row in - ProviderAccountUsageSnapshot( - id: ProviderAccountIdentity(source: self.sourceName, opaqueID: String(row.number)), + let id = ProviderAccountIdentity(source: self.sourceName, opaqueID: String(row.number)) + let snapshot = self.usageSnapshot( + for: row, + previous: previousByID[id], + now: now) + return ProviderAccountUsageSnapshot( + id: id, provider: .claude, displayLabel: self.displayLabel(for: row), isActive: row.isActive, canActivate: !row.isActive && self.canActivate(row), - snapshot: self.usageSnapshot(for: row, now: now), - error: self.errorText(for: row), + snapshot: snapshot, + error: self.errorText(for: row, snapshot: snapshot, now: now), sourceLabel: self.sourceLabel) } } @@ -50,8 +61,85 @@ public enum ClaudeSwapAccountProjection { row.email.isEmpty ? "Account \(row.number)" : row.email } - private static func usageSnapshot(for row: ClaudeSwapAccountRow, now: Date) -> UsageSnapshot? { - guard row.usageStatus == .ok else { return nil } + private static func usageSnapshot( + for row: ClaudeSwapAccountRow, + previous: ProviderAccountUsageSnapshot?, + now: Date) -> UsageSnapshot? + { + switch row.usageStatus { + case .ok, .unavailable: + if let projected = self.projectedUsageSnapshot(for: row, now: now) { + if row.usageStatus == .ok { + return projected + } + if let pruned = self.prunedAtLimitSnapshot( + projected, + identity: projected.identity ?? self.identitySnapshot(for: row), + now: now) + { + return pruned + } + } + guard row.usageStatus == .unavailable else { return nil } + return self.retainedAtLimitSnapshot(previous, matching: row, now: now) + case .tokenExpired, .reloginRequired, .apiKey, .keychainUnavailable, .noCredentials, .unknown: + return nil + } + } + + private static func retainedAtLimitSnapshot( + _ previous: ProviderAccountUsageSnapshot?, + matching row: ClaudeSwapAccountRow, + now: Date) -> UsageSnapshot? + { + guard let previous, let snapshot = previous.snapshot else { return nil } + let previousFingerprint = ClaudeSwapRetainedUsageStore.fingerprint(from: previous) + let rowFingerprint = ClaudeSwapRetainedUsageStore.fingerprint( + email: row.email, + slot: String(row.number)) + guard let previousFingerprint, let rowFingerprint, previousFingerprint == rowFingerprint else { + return nil + } + return self.prunedAtLimitSnapshot(snapshot, identity: self.identitySnapshot(for: row), now: now) + } + + /// Drops windows whose reset is in the past so a mixed snapshot cannot keep showing + /// an already-reset lane as "Resets now" just because a sibling is still exhausted. + private static func prunedAtLimitSnapshot( + _ snapshot: UsageSnapshot, + identity: ProviderIdentitySnapshot?, + now: Date) -> UsageSnapshot? + { + let primary = self.unexpiredWindow(snapshot.primary, now: now) + let secondary = self.unexpiredWindow(snapshot.secondary, now: now) + let extra = (snapshot.extraRateWindows ?? []).compactMap { named -> NamedRateWindow? in + guard let window = self.unexpiredWindow(named.window, now: now) else { return nil } + return NamedRateWindow( + id: named.id, + title: named.title, + window: window, + usageKnown: named.usageKnown) + } + let remaining = [primary, secondary].compactMap(\.self) + extra.map(\.window) + guard remaining.contains(where: { $0.usedPercent >= self.exhaustedUsedPercent }) else { + return nil + } + return UsageSnapshot( + primary: primary, + secondary: secondary, + extraRateWindows: extra.isEmpty ? nil : extra, + updatedAt: snapshot.updatedAt, + identity: identity, + dataConfidence: snapshot.dataConfidence) + } + + private static func unexpiredWindow(_ window: RateWindow?, now: Date) -> RateWindow? { + guard let window else { return nil } + guard let resetsAt = window.resetsAt, resetsAt > now else { return nil } + return window + } + + private static func projectedUsageSnapshot(for row: ClaudeSwapAccountRow, now: Date) -> UsageSnapshot? { let primary = row.fiveHour.map { window in RateWindow( usedPercent: window.usedPercent, @@ -73,11 +161,15 @@ public enum ClaudeSwapAccountProjection { secondary: secondary, extraRateWindows: scoped.isEmpty ? nil : scoped, updatedAt: now, - identity: ProviderIdentitySnapshot( - providerID: .claude, - accountEmail: self.displayLabel(for: row), - accountOrganization: nil, - loginMethod: self.sourceLabel)) + identity: self.identitySnapshot(for: row)) + } + + private static func identitySnapshot(for row: ClaudeSwapAccountRow) -> ProviderIdentitySnapshot { + ProviderIdentitySnapshot( + providerID: .claude, + accountEmail: self.displayLabel(for: row), + accountOrganization: nil, + loginMethod: self.sourceLabel) } private static func scopedRateWindows(for row: ClaudeSwapAccountRow) -> [NamedRateWindow] { @@ -92,12 +184,10 @@ public enum ClaudeSwapAccountProjection { }) } - private static func errorText(for row: ClaudeSwapAccountRow) -> String? { + private static func errorText(for row: ClaudeSwapAccountRow, snapshot: UsageSnapshot?, now: Date) -> String? { switch row.usageStatus { case .ok: - row.fiveHour == nil && row.sevenDay == nil && self.scopedRateWindows(for: row).isEmpty - ? "No usage windows reported." - : nil + snapshot == nil ? "No usage windows reported." : nil case .tokenExpired: "Token expired. Switch to this account in claude-swap to refresh it." case .reloginRequired: @@ -109,12 +199,48 @@ public enum ClaudeSwapAccountProjection { case .noCredentials: "No stored credentials for this account slot." case .unavailable: - "Usage fetch failed." + self.atLimitNote(from: snapshot, now: now) ?? self.deferredPollingNote case let .unknown(raw): "Unrecognized claude-swap status: \(raw)" } } + private static func atLimitNote(from snapshot: UsageSnapshot?, now: Date) -> String? { + guard let snapshot else { return nil } + var parts: [String] = [] + if let primary = snapshot.primary { + self.appendLimit(named: "Session", window: primary, now: now, to: &parts) + } + if let secondary = snapshot.secondary { + self.appendLimit(named: "Weekly", window: secondary, now: now, to: &parts) + } + for extra in snapshot.extraRateWindows ?? [] { + self.appendLimit(named: self.scopedLimitName(extra.title), window: extra.window, now: now, to: &parts) + } + guard !parts.isEmpty else { return nil } + return parts.joined(separator: " ") + } + + private static func appendLimit( + named name: String, + window: RateWindow, + now: Date, + to parts: inout [String]) + { + guard window.usedPercent >= self.exhaustedUsedPercent else { return } + if let reset = UsageFormatter.resetLine(for: window, style: .countdown, now: now) { + parts.append("\(name) limit reached. \(reset).") + } else { + parts.append("\(name) limit reached.") + } + } + + private static func scopedLimitName(_ title: String) -> String { + let suffix = " only" + guard title.hasSuffix(suffix) else { return title } + return String(title.dropLast(suffix.count)) + } + private static func canActivate(_ row: ClaudeSwapAccountRow) -> Bool { switch row.usageStatus { case .ok, .apiKey, .unavailable: diff --git a/Sources/CodexBarCore/Providers/Claude/ClaudeSwap/ClaudeSwapRetainedUsageStore.swift b/Sources/CodexBarCore/Providers/Claude/ClaudeSwap/ClaudeSwapRetainedUsageStore.swift new file mode 100644 index 0000000000..b3cf34d6bd --- /dev/null +++ b/Sources/CodexBarCore/Providers/Claude/ClaudeSwap/ClaudeSwapRetainedUsageStore.swift @@ -0,0 +1,141 @@ +#if canImport(CryptoKit) +import CryptoKit +#else +import Crypto +#endif +import Foundation + +/// Slot-keyed usage windows from the last successful Claude Swap projection. +/// Display labels and emails stay out of the cache so one-shot CLI/dashboard +/// calls can retain at-limit bars without persisting identity. A SHA-256 +/// fingerprint binds those windows to the account that produced them. +public enum ClaudeSwapRetainedUsageStore { + private static let fingerprintPrefix = "fp:" + + public static func load() -> [ProviderAccountUsageSnapshot] { + guard let url = self.resolvedFileURL(), + let data = try? Data(contentsOf: url), + let records = try? JSONDecoder().decode([Record].self, from: data) + else { return [] } + return records.map(\.account) + } + + /// After a relaunch the in-memory array is empty even when this cache still + /// holds complete windows, so fall back to disk only when nothing is in memory. + public static func previousAccounts( + inMemory: [ProviderAccountUsageSnapshot]) -> [ProviderAccountUsageSnapshot] + { + inMemory.isEmpty ? self.load() : inMemory + } + + public static func save(_ accounts: [ProviderAccountUsageSnapshot]) { + guard let url = self.resolvedFileURL() else { return } + let records = accounts.compactMap(Record.init(account:)) + guard let data = try? JSONEncoder().encode(records) else { return } + try? FileManager.default.createDirectory( + at: url.deletingLastPathComponent(), + withIntermediateDirectories: true) + try? data.write(to: url, options: .atomic) + } + + /// In-memory equivalent of save/load, used to prove cache-shaped previous snapshots + /// still reject a different account in the same slot. + static func snapshotsForRetention( + _ accounts: [ProviderAccountUsageSnapshot]) -> [ProviderAccountUsageSnapshot] + { + accounts.compactMap(Record.init(account:)).map(\.account) + } + + static func fingerprint(email: String, slot: String) -> String? { + let trimmed = email.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + guard trimmed.contains("@") else { return nil } + let material = "\(slot)\u{0}\(trimmed)" + return SHA256.hash(data: Data(material.utf8)).map { String(format: "%02x", $0) }.joined() + } + + static func fingerprint(from account: ProviderAccountUsageSnapshot) -> String? { + if let stored = account.snapshot?.identity?.accountID, + stored.hasPrefix(self.fingerprintPrefix) + { + return String(stored.dropFirst(self.fingerprintPrefix.count)) + } + let email = account.snapshot?.identity?.accountEmail ?? account.displayLabel + if email.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + return nil + } + return self.fingerprint(email: email, slot: account.id.opaqueID) + } + + static func fingerprintAccountID(_ fingerprint: String) -> String { + self.fingerprintPrefix + fingerprint + } + + private static func resolvedFileURL() -> URL? { + if self.isRunningTests { return nil } + let base = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first + return base? + .appendingPathComponent("CodexBar", isDirectory: true) + .appendingPathComponent("claude-swap-retained-usage.json") + } + + private static var isRunningTests: Bool { + let environment = ProcessInfo.processInfo.environment + if environment["XCTestConfigurationFilePath"] != nil || environment["XCTestBundlePath"] != nil { + return true + } + if ProcessInfo.processInfo.processName.lowercased().contains("xctest") { + return true + } + return CommandLine.arguments.contains { $0.lowercased().contains(".xctest") } + } + + private struct Record: Codable { + var opaqueID: String + var accountFingerprint: String + var primary: RateWindow? + var secondary: RateWindow? + var extraRateWindows: [NamedRateWindow]? + var updatedAt: Date + + init?(account: ProviderAccountUsageSnapshot) { + guard account.id.source == ClaudeSwapAccountProjection.sourceName, + let snapshot = account.snapshot + else { return nil } + self.opaqueID = account.id.opaqueID + guard let fingerprint = ClaudeSwapRetainedUsageStore.fingerprint( + email: snapshot.identity?.accountEmail ?? account.displayLabel, + slot: account.id.opaqueID) + else { + return nil + } + self.accountFingerprint = fingerprint + self.primary = snapshot.primary + self.secondary = snapshot.secondary + self.extraRateWindows = snapshot.extraRateWindows + self.updatedAt = snapshot.updatedAt + } + + var account: ProviderAccountUsageSnapshot { + ProviderAccountUsageSnapshot( + id: ProviderAccountIdentity( + source: ClaudeSwapAccountProjection.sourceName, + opaqueID: self.opaqueID), + provider: .claude, + displayLabel: "", + isActive: false, + snapshot: UsageSnapshot( + primary: self.primary, + secondary: self.secondary, + extraRateWindows: self.extraRateWindows, + updatedAt: self.updatedAt, + identity: ProviderIdentitySnapshot( + providerID: .claude, + accountEmail: nil, + accountOrganization: nil, + loginMethod: ClaudeSwapAccountProjection.sourceLabel, + accountID: ClaudeSwapRetainedUsageStore.fingerprintAccountID(self.accountFingerprint))), + error: nil, + sourceLabel: ClaudeSwapAccountProjection.sourceLabel) + } + } +} diff --git a/Tests/CodexBarTests/CLICardsClaudeSwapTests.swift b/Tests/CodexBarTests/CLICardsClaudeSwapTests.swift index a739776e4c..2746a9fbae 100644 --- a/Tests/CodexBarTests/CLICardsClaudeSwapTests.swift +++ b/Tests/CodexBarTests/CLICardsClaudeSwapTests.swift @@ -271,7 +271,7 @@ struct CLICardsClaudeSwapTests { "Re-login required. Re-authenticate this account in claude-swap.", "claude-swap could not read the active account's Keychain entry.", "No stored credentials for this account slot.", - "Usage fetch failed.", + "Polling deferred until a limit resets.", "Unrecognized claude-swap status: future_status", "No usage windows reported.", ]) @@ -279,7 +279,7 @@ struct CLICardsClaudeSwapTests { @Test func `active sentinel account remains active and metrics less in full and brief cards`() async { - let problem = "Usage fetch failed." + let problem = "Polling deferred until a limit resets." let output = await CLIClaudeSwapCards.fetch( eligible: true, executablePath: "/fake/cswap", @@ -315,6 +315,83 @@ struct CLICardsClaudeSwapTests { #expect(rows.first?.usedPercent == nil) } + @Test + func `unavailable at limit windows keep metrics and name the exhausted window`() async { + let reset = Date(timeIntervalSince1970: 1_700_003_600) + let output = await CLIClaudeSwapCards.fetch( + eligible: true, + executablePath: "/fake/cswap", + renderOptions: self.renderOptions(), + ambientFetch: { self.ambientOutput(failed: true) }, + accountListReader: { _ in + ClaudeSwapAccountList(activeAccountNumber: 1, accounts: [ + ClaudeSwapAccountRow( + number: 1, + email: "limited@example.com", + isActive: true, + usageStatus: .unavailable, + fiveHour: ClaudeSwapUsageWindow(usedPercent: 100, resetsAt: reset), + sevenDay: ClaudeSwapUsageWindow(usedPercent: 100, resetsAt: reset)), + self.row(number: 2), + ]) + }) + + #expect(output.exitCode == .success) + let activeCard = output.cards.first + #expect(activeCard?.accountLine == "limited@example.com") + #expect(activeCard?.isActive == true) + #expect(activeCard?.accountProblem == + "Session limit reached. Resets in 1h. Weekly limit reached. Resets in 1h.") + #expect(activeCard?.metrics.isEmpty == false) + #expect(activeCard?.metrics.contains { $0.remainingPercent == 0 } == true) + #expect(activeCard?.accountProblem?.contains("Usage fetch failed") != true) + + let rows = CLICardsBriefRenderer.makeRows(cards: activeCard.map { [$0] } ?? []) + #expect(rows.first?.accountProblem?.contains("Session limit reached") == true) + #expect(rows.first?.usedPercent == 100) + } + + @Test + func `unavailable null usage retains previous CLI windows`() async { + let reset = Date(timeIntervalSince1970: 1_700_003_600) + let previous = ClaudeSwapAccountProjection.accountSnapshots( + from: ClaudeSwapAccountList(activeAccountNumber: 1, accounts: [ + ClaudeSwapAccountRow( + number: 1, + email: "limited@example.com", + isActive: true, + usageStatus: .ok, + fiveHour: ClaudeSwapUsageWindow(usedPercent: 100, resetsAt: reset), + sevenDay: ClaudeSwapUsageWindow(usedPercent: 40, resetsAt: reset)), + ]), + now: Date(timeIntervalSince1970: 1_700_000_000)) + let output = await CLIClaudeSwapCards.fetch( + eligible: true, + executablePath: "/fake/cswap", + renderOptions: self.renderOptions(), + ambientFetch: { self.ambientOutput(failed: true) }, + accountListReader: { _ in + ClaudeSwapAccountList(activeAccountNumber: 1, accounts: [ + self.row( + number: 1, + active: true, + status: .unavailable, + email: "limited@example.com", + hasUsage: false), + self.row(number: 2), + ]) + }, + previousAccounts: previous) + + #expect(output.exitCode == .success) + let activeCard = output.cards.first + #expect(activeCard?.accountLine == "limited@example.com") + #expect(activeCard?.metrics.isEmpty == false) + #expect(activeCard?.metrics.contains { $0.remainingPercent == 0 } == true) + #expect(activeCard?.accountProblem?.contains("Session limit reached") == true) + #expect(activeCard?.accountProblem?.contains("Usage fetch failed") != true) + } + @Test func `blank executable path preserves ambient output and fails distinctly`() async { let ambient = self.ambientOutput() diff --git a/Tests/CodexBarTests/CLIServeWebUITests.swift b/Tests/CodexBarTests/CLIServeWebUITests.swift index aa24408a07..d2684f33c0 100644 --- a/Tests/CodexBarTests/CLIServeWebUITests.swift +++ b/Tests/CodexBarTests/CLIServeWebUITests.swift @@ -30,6 +30,16 @@ struct CLIServeWebUITests { #expect(CLIServeWebUI.iconResponse(name: "../etc/passwd") == nil) } + @Test + func `web ui renders account windows alongside an error note`() { + let html = self.html + let errorAppend = "card.append(node(\"p\", \"error-message\", account.error));" + #expect(html.contains(errorAppend)) + #expect(!html.contains(errorAppend + "\n return card;")) + #expect(html.contains( + "for (const window of visibleWindows(account.windows)) windows.append(renderWindow(window))")) + } + @Test func `web ui skips windows the snapshot marks idle`() { let html = self.html diff --git a/Tests/CodexBarTests/ClaudeProviderRuntimeTests.swift b/Tests/CodexBarTests/ClaudeProviderRuntimeTests.swift index f9c35ca98f..19e36e9b02 100644 --- a/Tests/CodexBarTests/ClaudeProviderRuntimeTests.swift +++ b/Tests/CodexBarTests/ClaudeProviderRuntimeTests.swift @@ -177,6 +177,48 @@ struct ClaudeProviderRuntimeTests { #expect(store.claudeSwapTransientState.lastErrorAccountID == nil) } + @Test + func `unavailable refresh retains previous at limit snapshot`() async throws { + let (settings, store) = self.makeStore() + let executable = try self.makeUnavailableListExecutable() + let metadata = try #require(ProviderRegistry.shared.metadata[.claude]) + settings.setProviderEnabled(provider: .claude, metadata: metadata, enabled: true) + settings.claudeSwapExecutablePath = executable + settings.claudeSwapEnabled = true + let now = Date() + let previous = ClaudeSwapAccountProjection.accountSnapshots( + from: ClaudeSwapAccountList( + activeAccountNumber: 1, + accounts: [ + ClaudeSwapAccountRow( + number: 1, + email: "a@b.c", + isActive: true, + usageStatus: .ok, + fiveHour: ClaudeSwapUsageWindow( + usedPercent: 100, + resetsAt: now.addingTimeInterval(3600)), + sevenDay: ClaudeSwapUsageWindow( + usedPercent: 100, + resetsAt: now.addingTimeInterval(86400))), + ]), + now: now) + store.claudeSwapAccountSnapshots = previous + + await store.refreshClaudeSwapAccounts() + + let account = try #require(store.claudeSwapAccountSnapshots.first) + #expect(account.id == ProviderAccountIdentity(source: "claude-swap", opaqueID: "1")) + #expect(account.snapshot?.primary?.usedPercent == 100) + #expect(account.snapshot?.secondary?.usedPercent == 100) + #expect(account.snapshot?.updatedAt == now) + let error = try #require(account.error) + #expect(error.contains("Session limit reached")) + #expect(error.contains("Weekly limit reached")) + #expect(!error.contains("Usage fetch failed")) + #expect(store.claudeSwapLastError == nil) + } + private func makeStore() -> (SettingsStore, UsageStore) { let suite = "ClaudeProviderRuntimeTests-\(UUID().uuidString)" let defaults = UserDefaults(suiteName: suite)! @@ -242,6 +284,28 @@ struct ClaudeProviderRuntimeTests { return url.path } + private func makeUnavailableListExecutable() throws -> String { + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent("claude-unavailable-runtime-tests-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + let url = directory.appendingPathComponent("cswap") + let script = """ + #!/bin/sh + if [ "$1" = "--version" ]; then + echo 'cswap 0.22.0' + exit 0 + fi + cat <<'EOF' + {"schemaVersion":1,"activeAccountNumber":1,"accounts":[ + {"number":1,"email":"a@b.c","active":true,"usageStatus":"unavailable","usage":null} + ]} + EOF + """ + try script.write(to: url, atomically: true, encoding: .utf8) + try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: url.path) + return url.path + } + private func makeFailedSwitchExecutable() throws -> String { let directory = FileManager.default.temporaryDirectory .appendingPathComponent("claude-failed-switch-runtime-tests-\(UUID().uuidString)", isDirectory: true) diff --git a/Tests/CodexBarTests/ClaudeSwapAccountProjectionTests.swift b/Tests/CodexBarTests/ClaudeSwapAccountProjectionTests.swift index 567a069eca..dadf438bc9 100644 --- a/Tests/CodexBarTests/ClaudeSwapAccountProjectionTests.swift +++ b/Tests/CodexBarTests/ClaudeSwapAccountProjectionTests.swift @@ -80,7 +80,6 @@ struct ClaudeSwapAccountProjectionTests { (.apiKey, "API-key account"), (.keychainUnavailable, "Keychain"), (.noCredentials, "No stored credentials"), - (.unavailable, "Usage fetch failed"), (.unknown("mystery"), "mystery"), ] @@ -101,11 +100,448 @@ struct ClaudeSwapAccountProjectionTests { #expect(snapshot.snapshot == nil) let error = try #require(snapshot.error) #expect(error.contains(entry.1)) - let expectedCanActivate = entry.0 == .apiKey || entry.0 == .unavailable - #expect(snapshot.canActivate == expectedCanActivate) + #expect(snapshot.canActivate == (entry.0 == .apiKey)) } } + @Test + func `unavailable without windows or prior snapshot reports deferred polling`() throws { + let list = ClaudeSwapAccountList( + activeAccountNumber: nil, + accounts: [ + ClaudeSwapAccountRow( + number: 1, + email: "a@b.c", + isActive: false, + usageStatus: .unavailable, + fiveHour: nil, + sevenDay: nil), + ]) + + let snapshot = try #require(ClaudeSwapAccountProjection.accountSnapshots(from: list, now: self.now).first) + #expect(snapshot.snapshot == nil) + #expect(snapshot.error == "Polling deferred until a limit resets.") + #expect(snapshot.canActivate == true) + #expect(snapshot.error?.contains("Usage fetch failed") != true) + } + + @Test + func `projects usage windows even when status is unavailable`() throws { + let reset = Date(timeIntervalSince1970: 1_782_003_600) + let list = ClaudeSwapAccountList( + activeAccountNumber: 1, + accounts: [ + ClaudeSwapAccountRow( + number: 1, + email: "a@b.c", + isActive: true, + usageStatus: .unavailable, + fiveHour: ClaudeSwapUsageWindow(usedPercent: 100, resetsAt: reset), + sevenDay: ClaudeSwapUsageWindow(usedPercent: 42, resetsAt: nil)), + ]) + + let account = try #require(ClaudeSwapAccountProjection.accountSnapshots(from: list, now: self.now).first) + let snapshot = try #require(account.snapshot) + #expect(snapshot.primary?.usedPercent == 100) + #expect(snapshot.secondary == nil) + #expect(account.error == "Session limit reached. Resets in 1h.") + #expect(account.error?.contains("Usage fetch failed") != true) + } + + @Test + func `unavailable attached windows drop expired lanes and keep remaining at limit`() throws { + let list = ClaudeSwapAccountList( + activeAccountNumber: 1, + accounts: [ + ClaudeSwapAccountRow( + number: 1, + email: "a@b.c", + isActive: true, + usageStatus: .unavailable, + fiveHour: ClaudeSwapUsageWindow( + usedPercent: 100, + resetsAt: self.now.addingTimeInterval(-60)), + sevenDay: ClaudeSwapUsageWindow( + usedPercent: 100, + resetsAt: self.now.addingTimeInterval(86400))), + ]) + + let account = try #require(ClaudeSwapAccountProjection.accountSnapshots(from: list, now: self.now).first) + #expect(account.snapshot?.primary == nil) + #expect(account.snapshot?.secondary?.usedPercent == 100) + let error = try #require(account.error) + #expect(error.contains("Weekly limit reached")) + #expect(!error.contains("Session limit reached")) + #expect(!error.contains("Resets now")) + } + + @Test + func `names each exhausted window including scoped models`() throws { + let sessionReset = Date(timeIntervalSince1970: 1_782_003_600) + let weeklyReset = Date(timeIntervalSince1970: 1_782_259_200) + let list = ClaudeSwapAccountList( + activeAccountNumber: 1, + accounts: [ + ClaudeSwapAccountRow( + number: 1, + email: "a@b.c", + isActive: true, + usageStatus: .unavailable, + fiveHour: ClaudeSwapUsageWindow(usedPercent: 100, resetsAt: sessionReset), + sevenDay: ClaudeSwapUsageWindow(usedPercent: 100, resetsAt: weeklyReset), + scoped: [ + ClaudeSwapScopedUsageWindow(name: "Fable", usedPercent: 100, resetsAt: weeklyReset), + ]), + ]) + + let account = try #require(ClaudeSwapAccountProjection.accountSnapshots(from: list, now: self.now).first) + #expect(account.snapshot?.primary?.usedPercent == 100) + #expect(account.snapshot?.secondary?.usedPercent == 100) + #expect(account.snapshot?.extraRateWindows?.first?.window.usedPercent == 100) + #expect(account.error == [ + "Session limit reached. Resets in 1h.", + "Weekly limit reached. Resets in 3d.", + "Fable limit reached. Resets in 3d.", + ].joined(separator: " ")) + } + + @Test + func `unavailable without windows retains previous snapshot as current at limit usage`() throws { + let reset = Date(timeIntervalSince1970: 1_782_259_200) + let previousList = ClaudeSwapAccountList( + activeAccountNumber: 1, + accounts: [ + ClaudeSwapAccountRow( + number: 1, + email: "a@b.c", + isActive: true, + usageStatus: .ok, + fiveHour: ClaudeSwapUsageWindow(usedPercent: 100, resetsAt: reset), + sevenDay: ClaudeSwapUsageWindow(usedPercent: 100, resetsAt: reset)), + ]) + let previous = ClaudeSwapAccountProjection.accountSnapshots(from: previousList, now: self.now) + let list = ClaudeSwapAccountList( + activeAccountNumber: 1, + accounts: [ + ClaudeSwapAccountRow( + number: 1, + email: "a@b.c", + isActive: true, + usageStatus: .unavailable, + fiveHour: nil, + sevenDay: nil), + ]) + + let account = try #require( + ClaudeSwapAccountProjection.accountSnapshots( + from: list, + previousAccounts: previous, + now: self.now.addingTimeInterval(3600)).first) + #expect(account.id == ProviderAccountIdentity(source: "claude-swap", opaqueID: "1")) + #expect(account.snapshot?.primary?.usedPercent == 100) + #expect(account.snapshot?.secondary?.usedPercent == 100) + #expect(account.snapshot?.updatedAt == self.now) + let error = try #require(account.error) + #expect(error.contains("Session limit reached")) + #expect(error.contains("Weekly limit reached")) + #expect(!error.contains("Usage fetch failed")) + #expect(!error.contains("last successful update")) + } + + @Test + func `unavailable retain drops expired windows and keeps remaining at limit lanes`() throws { + let previous = ClaudeSwapAccountProjection.accountSnapshots( + from: ClaudeSwapAccountList( + activeAccountNumber: 1, + accounts: [ + ClaudeSwapAccountRow( + number: 1, + email: "a@b.c", + isActive: true, + usageStatus: .ok, + fiveHour: ClaudeSwapUsageWindow( + usedPercent: 100, + resetsAt: self.now.addingTimeInterval(-60)), + sevenDay: ClaudeSwapUsageWindow( + usedPercent: 100, + resetsAt: self.now.addingTimeInterval(86400))), + ]), + now: self.now) + let list = ClaudeSwapAccountList( + activeAccountNumber: 1, + accounts: [ + ClaudeSwapAccountRow( + number: 1, + email: "a@b.c", + isActive: true, + usageStatus: .unavailable, + fiveHour: nil, + sevenDay: nil), + ]) + + let account = try #require( + ClaudeSwapAccountProjection.accountSnapshots( + from: list, + previousAccounts: previous, + now: self.now).first) + #expect(account.snapshot?.primary == nil) + #expect(account.snapshot?.secondary?.usedPercent == 100) + let error = try #require(account.error) + #expect(error.contains("Weekly limit reached")) + #expect(!error.contains("Session limit reached")) + #expect(!error.contains("Resets now")) + } + + @Test + func `unavailable retain drops a snapshot whose at limit windows have all reset`() throws { + let previous = ClaudeSwapAccountProjection.accountSnapshots( + from: ClaudeSwapAccountList( + activeAccountNumber: 1, + accounts: [ + ClaudeSwapAccountRow( + number: 1, + email: "a@b.c", + isActive: true, + usageStatus: .ok, + fiveHour: ClaudeSwapUsageWindow( + usedPercent: 100, + resetsAt: self.now.addingTimeInterval(-3600)), + sevenDay: ClaudeSwapUsageWindow( + usedPercent: 100, + resetsAt: self.now.addingTimeInterval(-60))), + ]), + now: self.now) + let list = ClaudeSwapAccountList( + activeAccountNumber: 1, + accounts: [ + ClaudeSwapAccountRow( + number: 1, + email: "a@b.c", + isActive: true, + usageStatus: .unavailable, + fiveHour: nil, + sevenDay: nil), + ]) + + let account = try #require( + ClaudeSwapAccountProjection.accountSnapshots( + from: list, + previousAccounts: previous, + now: self.now).first) + #expect(account.snapshot == nil) + #expect(account.error == "Polling deferred until a limit resets.") + } + + @Test + func `unavailable retain drops exhausted windows without a reset timestamp`() throws { + let previous = ClaudeSwapAccountProjection.accountSnapshots( + from: ClaudeSwapAccountList( + activeAccountNumber: 1, + accounts: [ + ClaudeSwapAccountRow( + number: 1, + email: "a@b.c", + isActive: true, + usageStatus: .ok, + fiveHour: ClaudeSwapUsageWindow(usedPercent: 100, resetsAt: nil), + sevenDay: ClaudeSwapUsageWindow( + usedPercent: 100, + resetsAt: self.now.addingTimeInterval(86400))), + ]), + now: self.now) + let list = ClaudeSwapAccountList( + activeAccountNumber: 1, + accounts: [ + ClaudeSwapAccountRow( + number: 1, + email: "a@b.c", + isActive: true, + usageStatus: .unavailable, + fiveHour: nil, + sevenDay: nil), + ]) + + let account = try #require( + ClaudeSwapAccountProjection.accountSnapshots( + from: list, + previousAccounts: previous, + now: self.now).first) + #expect(account.snapshot?.primary == nil) + #expect(account.snapshot?.secondary?.usedPercent == 100) + let error = try #require(account.error) + #expect(error.contains("Weekly limit reached")) + #expect(!error.contains("Session limit reached")) + } + + @Test + func `unavailable retain drops unknown reset lanes that are not exhausted`() throws { + let previous = ClaudeSwapAccountProjection.accountSnapshots( + from: ClaudeSwapAccountList( + activeAccountNumber: 1, + accounts: [ + ClaudeSwapAccountRow( + number: 1, + email: "a@b.c", + isActive: true, + usageStatus: .ok, + fiveHour: ClaudeSwapUsageWindow(usedPercent: 40, resetsAt: nil), + sevenDay: ClaudeSwapUsageWindow( + usedPercent: 100, + resetsAt: self.now.addingTimeInterval(86400))), + ]), + now: self.now) + let list = ClaudeSwapAccountList( + activeAccountNumber: 1, + accounts: [ + ClaudeSwapAccountRow( + number: 1, + email: "a@b.c", + isActive: true, + usageStatus: .unavailable, + fiveHour: nil, + sevenDay: nil), + ]) + + let account = try #require( + ClaudeSwapAccountProjection.accountSnapshots( + from: list, + previousAccounts: previous, + now: self.now).first) + #expect(account.snapshot?.primary == nil) + #expect(account.snapshot?.secondary?.usedPercent == 100) + let error = try #require(account.error) + #expect(error.contains("Weekly limit reached")) + #expect(!error.contains("Session limit reached")) + } + + @Test + func `token expired does not retain a previous usage snapshot`() throws { + let previousList = ClaudeSwapAccountList( + activeAccountNumber: 1, + accounts: [ + ClaudeSwapAccountRow( + number: 1, + email: "a@b.c", + isActive: true, + usageStatus: .ok, + fiveHour: ClaudeSwapUsageWindow(usedPercent: 100, resetsAt: nil), + sevenDay: nil), + ]) + let previous = ClaudeSwapAccountProjection.accountSnapshots(from: previousList, now: self.now) + let list = ClaudeSwapAccountList( + activeAccountNumber: 1, + accounts: [ + ClaudeSwapAccountRow( + number: 1, + email: "a@b.c", + isActive: true, + usageStatus: .tokenExpired, + fiveHour: nil, + sevenDay: nil), + ]) + + let account = try #require( + ClaudeSwapAccountProjection.accountSnapshots( + from: list, + previousAccounts: previous, + now: self.now).first) + #expect(account.snapshot == nil) + #expect(account.error?.contains("Token expired") == true) + } + + @Test + func `token expired with cached windows stays metrics less`() throws { + let list = ClaudeSwapAccountList( + activeAccountNumber: 1, + accounts: [ + ClaudeSwapAccountRow( + number: 1, + email: "a@b.c", + isActive: true, + usageStatus: .tokenExpired, + fiveHour: ClaudeSwapUsageWindow(usedPercent: 100, resetsAt: nil), + sevenDay: ClaudeSwapUsageWindow(usedPercent: 80, resetsAt: nil)), + ]) + + let account = try #require(ClaudeSwapAccountProjection.accountSnapshots(from: list, now: self.now).first) + #expect(account.snapshot == nil) + #expect(account.error?.contains("Token expired") == true) + } + + @Test + func `unavailable does not reuse a previous snapshot from a different email`() throws { + let reset = Date(timeIntervalSince1970: 1_782_259_200) + let previous = ClaudeSwapAccountProjection.accountSnapshots( + from: ClaudeSwapAccountList( + activeAccountNumber: 1, + accounts: [ + ClaudeSwapAccountRow( + number: 1, + email: "old@example.com", + isActive: true, + usageStatus: .ok, + fiveHour: ClaudeSwapUsageWindow(usedPercent: 100, resetsAt: reset), + sevenDay: nil), + ]), + now: self.now) + let list = ClaudeSwapAccountList( + activeAccountNumber: 1, + accounts: [ + ClaudeSwapAccountRow( + number: 1, + email: "new@example.com", + isActive: true, + usageStatus: .unavailable, + fiveHour: nil, + sevenDay: nil), + ]) + + let account = try #require( + ClaudeSwapAccountProjection.accountSnapshots( + from: list, + previousAccounts: previous, + now: self.now).first) + #expect(account.displayLabel == "new@example.com") + #expect(account.snapshot == nil) + #expect(account.error == "Polling deferred until a limit resets.") + } + + @Test + func `unavailable does not retain a previous snapshot that is not at a limit`() throws { + let previous = ClaudeSwapAccountProjection.accountSnapshots( + from: ClaudeSwapAccountList( + activeAccountNumber: 1, + accounts: [ + ClaudeSwapAccountRow( + number: 1, + email: "a@b.c", + isActive: true, + usageStatus: .ok, + fiveHour: ClaudeSwapUsageWindow(usedPercent: 20, resetsAt: nil), + sevenDay: nil), + ]), + now: self.now) + let list = ClaudeSwapAccountList( + activeAccountNumber: 1, + accounts: [ + ClaudeSwapAccountRow( + number: 1, + email: "a@b.c", + isActive: true, + usageStatus: .unavailable, + fiveHour: nil, + sevenDay: nil), + ]) + + let account = try #require( + ClaudeSwapAccountProjection.accountSnapshots( + from: list, + previousAccounts: previous, + now: self.now).first) + #expect(account.snapshot == nil) + #expect(account.error == "Polling deferred until a limit resets.") + } + @Test func `ok row without windows reports missing usage instead of an empty card`() throws { let list = ClaudeSwapAccountList( @@ -197,4 +633,195 @@ struct ClaudeSwapAccountProjectionTests { let snapshot = try #require(ClaudeSwapAccountProjection.accountSnapshots(from: list, now: self.now).first) #expect(snapshot.displayLabel == "Account 3") } + + @Test + func `unavailable retain ignores cached windows after the slot account changes`() throws { + let reset = Date(timeIntervalSince1970: 1_782_259_200) + let previous = ClaudeSwapAccountProjection.accountSnapshots( + from: ClaudeSwapAccountList( + activeAccountNumber: 1, + accounts: [ + ClaudeSwapAccountRow( + number: 1, + email: "old@example.com", + isActive: true, + usageStatus: .ok, + fiveHour: ClaudeSwapUsageWindow(usedPercent: 100, resetsAt: reset), + sevenDay: nil), + ]), + now: self.now) + let cached = ClaudeSwapRetainedUsageStore.snapshotsForRetention(previous) + let list = ClaudeSwapAccountList( + activeAccountNumber: 1, + accounts: [ + ClaudeSwapAccountRow( + number: 1, + email: "new@example.com", + isActive: true, + usageStatus: .unavailable, + fiveHour: nil, + sevenDay: nil), + ]) + + let account = try #require( + ClaudeSwapAccountProjection.accountSnapshots( + from: list, + previousAccounts: cached, + now: self.now).first) + #expect(account.displayLabel == "new@example.com") + #expect(account.snapshot == nil) + #expect(account.error == "Polling deferred until a limit resets.") + } + + @Test + func `unavailable retain ignores cached windows when the slot has no email`() throws { + let reset = Date(timeIntervalSince1970: 1_782_259_200) + let previous = ClaudeSwapAccountProjection.accountSnapshots( + from: ClaudeSwapAccountList( + activeAccountNumber: 1, + accounts: [ + ClaudeSwapAccountRow( + number: 1, + email: "", + isActive: true, + usageStatus: .ok, + fiveHour: ClaudeSwapUsageWindow(usedPercent: 100, resetsAt: reset), + sevenDay: nil), + ]), + now: self.now) + let cached = ClaudeSwapRetainedUsageStore.snapshotsForRetention(previous) + #expect(cached.isEmpty) + let list = ClaudeSwapAccountList( + activeAccountNumber: 1, + accounts: [ + ClaudeSwapAccountRow( + number: 1, + email: "", + isActive: true, + usageStatus: .unavailable, + fiveHour: nil, + sevenDay: nil), + ]) + + let account = try #require( + ClaudeSwapAccountProjection.accountSnapshots( + from: list, + previousAccounts: previous, + now: self.now).first) + #expect(account.displayLabel == "Account 1") + #expect(account.snapshot == nil) + #expect(account.error == "Polling deferred until a limit resets.") + } + + @Test + func `unavailable retain keeps cached windows for the same slot account`() throws { + let reset = Date(timeIntervalSince1970: 1_782_259_200) + let previous = ClaudeSwapAccountProjection.accountSnapshots( + from: ClaudeSwapAccountList( + activeAccountNumber: 1, + accounts: [ + ClaudeSwapAccountRow( + number: 1, + email: "same@example.com", + isActive: true, + usageStatus: .ok, + fiveHour: ClaudeSwapUsageWindow(usedPercent: 100, resetsAt: reset), + sevenDay: nil), + ]), + now: self.now) + let cached = ClaudeSwapRetainedUsageStore.snapshotsForRetention(previous) + let list = ClaudeSwapAccountList( + activeAccountNumber: 1, + accounts: [ + ClaudeSwapAccountRow( + number: 1, + email: "same@example.com", + isActive: true, + usageStatus: .unavailable, + fiveHour: nil, + sevenDay: nil), + ]) + + let account = try #require( + ClaudeSwapAccountProjection.accountSnapshots( + from: list, + previousAccounts: cached, + now: self.now).first) + #expect(account.snapshot?.primary?.usedPercent == 100) + #expect(account.snapshot?.identity?.accountEmail == "same@example.com") + #expect(account.error?.contains("Session limit reached") == true) + } + + @Test + func `unavailable retain ignores a cache entry with no account discriminator`() throws { + let reset = Date(timeIntervalSince1970: 1_782_259_200) + let previous = ClaudeSwapAccountProjection.accountSnapshots( + from: ClaudeSwapAccountList( + activeAccountNumber: 1, + accounts: [ + ClaudeSwapAccountRow( + number: 1, + email: "old@example.com", + isActive: true, + usageStatus: .ok, + fiveHour: ClaudeSwapUsageWindow(usedPercent: 100, resetsAt: reset), + sevenDay: nil), + ]), + now: self.now) + let stripped = previous.map { account in + ProviderAccountUsageSnapshot( + id: account.id, + provider: account.provider, + displayLabel: "", + isActive: account.isActive, + snapshot: account.snapshot.map { snapshot in + UsageSnapshot( + primary: snapshot.primary, + secondary: snapshot.secondary, + extraRateWindows: snapshot.extraRateWindows, + updatedAt: snapshot.updatedAt, + identity: nil) + }, + error: nil, + sourceLabel: account.sourceLabel) + } + let list = ClaudeSwapAccountList( + activeAccountNumber: 1, + accounts: [ + ClaudeSwapAccountRow( + number: 1, + email: "old@example.com", + isActive: true, + usageStatus: .unavailable, + fiveHour: nil, + sevenDay: nil), + ]) + + let account = try #require( + ClaudeSwapAccountProjection.accountSnapshots( + from: list, + previousAccounts: stripped, + now: self.now).first) + #expect(account.snapshot == nil) + #expect(account.error == "Polling deferred until a limit resets.") + } + + @Test + func `previous accounts prefer in-memory snapshots over an empty cache load`() { + let previous = ClaudeSwapAccountProjection.accountSnapshots( + from: ClaudeSwapAccountList( + activeAccountNumber: 1, + accounts: [ + ClaudeSwapAccountRow( + number: 1, + email: "work@example.com", + isActive: true, + usageStatus: .ok, + fiveHour: ClaudeSwapUsageWindow(usedPercent: 40, resetsAt: nil), + sevenDay: nil), + ]), + now: self.now) + #expect(ClaudeSwapRetainedUsageStore.previousAccounts(inMemory: previous).count == previous.count) + #expect(ClaudeSwapRetainedUsageStore.previousAccounts(inMemory: []).isEmpty) + } } diff --git a/Tests/CodexBarTests/MenuCardClaudeSwapAccountTests.swift b/Tests/CodexBarTests/MenuCardClaudeSwapAccountTests.swift index eec9a75fb0..ba1de6b4eb 100644 --- a/Tests/CodexBarTests/MenuCardClaudeSwapAccountTests.swift +++ b/Tests/CodexBarTests/MenuCardClaudeSwapAccountTests.swift @@ -110,4 +110,53 @@ struct MenuCardClaudeSwapAccountTests { #expect(!model.email.contains("personal@example.com")) #expect(!model.email.contains("example.com")) } + + @Test + func `at limit unavailable card keeps usage bars and names the exhausted window`() throws { + let now = Date(timeIntervalSince1970: 1_782_000_000) + let metadata = try #require(ProviderDefaults.metadata[.claude]) + let list = ClaudeSwapAccountList( + activeAccountNumber: 1, + accounts: [ + ClaudeSwapAccountRow( + number: 1, + email: "limited@example.com", + isActive: true, + usageStatus: .unavailable, + fiveHour: ClaudeSwapUsageWindow(usedPercent: 100, resetsAt: now.addingTimeInterval(3600)), + sevenDay: ClaudeSwapUsageWindow(usedPercent: 100, resetsAt: now.addingTimeInterval(86400))), + ]) + let account = try #require(ClaudeSwapAccountProjection.accountSnapshots(from: list, now: now).first) + let snapshot = try #require(account.snapshot) + + let model = UsageMenuCardView.Model.make(.init( + provider: .claude, + metadata: metadata, + snapshot: snapshot, + credits: nil, + creditsError: nil, + dashboard: nil, + dashboardError: nil, + tokenSnapshot: nil, + tokenError: nil, + account: AccountInfo(email: account.displayLabel, plan: nil), + isRefreshing: false, + lastError: account.error, + usageBarsShowUsed: true, + resetTimeDisplayStyle: .countdown, + tokenCostUsageEnabled: false, + showOptionalCreditsAndExtraUsage: false, + hidePersonalInfo: false, + now: now)) + + #expect(model.email == "limited@example.com") + let primary = try #require(model.metrics.first(where: { $0.id == "primary" })) + #expect(primary.percent == 100) + let secondary = try #require(model.metrics.first(where: { $0.id == "secondary" })) + #expect(secondary.percent == 100) + #expect(model.subtitleText == + "Session limit reached. Resets in 1h. Weekly limit reached. Resets in 1d.") + #expect(model.subtitleStyle == .error) + #expect(!model.subtitleText.contains("Usage fetch failed")) + } } diff --git a/Tests/CodexBarTests/PopupLocalizationTests.swift b/Tests/CodexBarTests/PopupLocalizationTests.swift index 97b46c327d..d42516da21 100644 --- a/Tests/CodexBarTests/PopupLocalizationTests.swift +++ b/Tests/CodexBarTests/PopupLocalizationTests.swift @@ -88,10 +88,10 @@ struct PopupLocalizationTests { now: now)) #expect(model.metrics.first?.title == "額度") - let apiKey = try #require(model.providerDetails.first { $0.title == "API key" }) + let apiKey = try #require(model.providerDetails.first { $0.title == "API 金鑰" }) #expect(apiKey.rows.map(\.label) == [ "API key budget", "API key remaining", "API key used", "Reset window", - "Today", "This week", "This month", "Rate limit", + "今天", "本週", "本月", "Rate limit", ]) #expect(apiKey.chart?.points.map(\.label) == ["Today", "This week", "This month"]) #expect(apiKey.rows.last?.value == "100 requests / 10s") diff --git a/TestsLinux/CLICardsClaudeSwapTests.swift b/TestsLinux/CLICardsClaudeSwapTests.swift index ec2b69ec5e..8bd502eef1 100644 --- a/TestsLinux/CLICardsClaudeSwapTests.swift +++ b/TestsLinux/CLICardsClaudeSwapTests.swift @@ -269,7 +269,7 @@ struct CLICardsClaudeSwapTests { "Token expired. Switch to this account in claude-swap to refresh it.", "claude-swap could not read the active account's Keychain entry.", "No stored credentials for this account slot.", - "Usage fetch failed.", + "Polling deferred until a limit resets.", "Unrecognized claude-swap status: future_status", "No usage windows reported.", ]) @@ -277,7 +277,7 @@ struct CLICardsClaudeSwapTests { @Test func `active sentinel account remains active and metrics less in full and brief cards`() async { - let problem = "Usage fetch failed." + let problem = "Polling deferred until a limit resets." let output = await CLIClaudeSwapCards.fetch( eligible: true, executablePath: "/fake/cswap", @@ -313,6 +313,42 @@ struct CLICardsClaudeSwapTests { #expect(rows.first?.usedPercent == nil) } + @Test + func `unavailable at limit windows keep metrics and name the exhausted window`() async { + let reset = Date(timeIntervalSince1970: 1_700_003_600) + let output = await CLIClaudeSwapCards.fetch( + eligible: true, + executablePath: "/fake/cswap", + renderOptions: self.renderOptions(), + ambientFetch: { self.ambientOutput(failed: true) }, + accountListReader: { _ in + ClaudeSwapAccountList(activeAccountNumber: 1, accounts: [ + ClaudeSwapAccountRow( + number: 1, + email: "limited@example.com", + isActive: true, + usageStatus: .unavailable, + fiveHour: ClaudeSwapUsageWindow(usedPercent: 100, resetsAt: reset), + sevenDay: ClaudeSwapUsageWindow(usedPercent: 100, resetsAt: reset)), + self.row(number: 2), + ]) + }) + + #expect(output.exitCode == .success) + let activeCard = output.cards.first + #expect(activeCard?.accountLine == "limited@example.com") + #expect(activeCard?.isActive == true) + #expect(activeCard?.accountProblem == + "Session limit reached. Resets in 1h. Weekly limit reached. Resets in 1h.") + #expect(activeCard?.metrics.isEmpty == false) + #expect(activeCard?.metrics.contains { $0.remainingPercent == 0 } == true) + #expect(activeCard?.accountProblem?.contains("Usage fetch failed") != true) + + let rows = CLICardsBriefRenderer.makeRows(cards: activeCard.map { [$0] } ?? []) + #expect(rows.first?.accountProblem?.contains("Session limit reached") == true) + #expect(rows.first?.usedPercent == 100) + } + @Test func `blank executable path preserves ambient output and fails distinctly`() async { let ambient = self.ambientOutput() diff --git a/docs/claude.md b/docs/claude.md index 55bc684f2e..8a6e1b7ca9 100644 --- a/docs/claude.md +++ b/docs/claude.md @@ -168,8 +168,12 @@ The accepted multi-account design in cards, a list failure retains the current ambient output, adds a distinct `Claude (claude-swap)` footer entry, and exits non-zero. - Sentinel statuses (`token_expired`, `api_key`, `keychain_unavailable`, `no_credentials`, - `unavailable`, and unknown future values) render as per-account notes instead of usage bars in both full and brief - cards. Active rows are marked `[active]`; no claude-swap row infers a plan badge. + and unknown future values) render as per-account notes instead of usage bars in both full and brief cards. When + `unavailable` means claude-swap deferred polling because a window is at 100%, CodexBar keeps that slot's last + projected usage bars and names the exhausted window (5-hour session, 7-day weekly, and/or a scoped model such as + Fable) plus its reset time — not "Usage fetch failed." A first refresh that is already `unavailable` with no + retained windows still notes that polling is deferred. Active rows are marked `[active]`; no claude-swap row infers + a plan badge. - Switching: an inactive account with usable source credentials shows “Switch Account…”. Clicking it runs exactly `cswap --switch-to --json`, validates the versioned result and requested slot, then refreshes both ambient Claude usage and every claude-swap account card. Switches are serialized; no automatic switching occurs. While