fix(claude): keep at-limit claude-swap cards complete (#3081)
* fix(claude): keep at-limit claude-swap cards complete Co-authored-by: Cursor <cursoragent@cursor.com> * docs: cite at-limit claude-swap card PR Co-authored-by: Cursor <cursoragent@cursor.com> * fix(claude): keep non-limit claude-swap sentinels metrics-less Project usage only for ok and unavailable slots, and retain a previous snapshot only when the email still matches and a 100% window has not reset. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(claude): drop expired windows from retained at-limit snapshots Keeping the whole previous snapshot after any sibling reset still showed "Resets now" on lanes that had already recovered. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(claude): retain at-limit swap windows in CLI and dashboard One-shot cards and dashboard now reuse the last slot windows so an unavailable cswap row with null usage keeps the exhausted bars. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(claude): bind retained at-limit windows to the slot account A SHA-256 fingerprint in the retained-usage cache rejects leftover 100% bars after the same slot is reused by a different account. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(claude): prune expired windows on attached unavailable snapshots Direct cswap unavailable payloads with mixed reset times now drop already-reset lanes the same way retained snapshots do. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(claude): keep retained at-limit bars on dashboard and app restart Seed the app projection from the retained-usage cache after a relaunch, and render dashboard windows alongside the deferred/limit note instead of returning after the error. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(claude): require an email before retaining at-limit windows Slot-only fingerprints reused bars after an email-less account was replaced. Decline persistence and reuse unless the row has an email, and move the changelog note to 0.54.1 Unreleased. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(claude): save retained usage only for current refreshes Stale claude-swap refreshes were writing the on-disk cache before the generation guard, and 100% windows without a future reset were kept forever. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(ci): flatten fingerprint matching and expect localized API key details SwiftFormat rejected the nested guard wrap, and OpenRouter detail titles now follow the #3084 localization pass. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(claude): drop retained windows without a future reset A non-exhausted session lane with no resetsAt was kept beside a weekly at-limit bar and re-saved until that weekly reset, so a five-hour value could linger for days. Co-authored-by: Cursor <cursoragent@cursor.com> --------- Co-authored-by: Cursor <cursoragent@cursor.com> Co-authored-by: Peter Steinberger <steipete@gmail.com>
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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");
|
||||
|
||||
+142
-16
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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()
|
||||
|
||||
+6
-2
@@ -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 <slot> --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
|
||||
|
||||
Reference in New Issue
Block a user