mirror of
https://github.com/tiennm99/ccs.git
synced 2026-10-12 20:15:40 +00:00
Merge pull request #1596 from kaitranntt/kai/feat/1595-bar-multi-profile-quota
feat(bar): per-profile subscription quota for ccs auth + ccsx auth accounts
This commit is contained in:
11 files changed
+2106
-98
No files matched your search
@@ -10,7 +10,7 @@ import PackageDescription
|
||||
// the core is verified.
|
||||
let package = Package(
|
||||
name: "CCSBar",
|
||||
platforms: [.macOS(.v13)],
|
||||
platforms: [.macOS(.v14)],
|
||||
products: [
|
||||
.executable(name: "CCSBar", targets: ["CCSBarApp"]),
|
||||
.executable(name: "ccs-bar-check", targets: ["CCSBarCheck"]),
|
||||
|
||||
@@ -36,6 +36,11 @@ struct BarMenuView: View {
|
||||
/// monotonically (ContentHeightKey.reduce takes the max), so the frame never
|
||||
/// thrashes downward.
|
||||
@State private var contentHeight: CGFloat = 0
|
||||
/// Multi-profile carousel: which provider page is currently visible. Resets to
|
||||
/// first provider on popover re-open (KISS — no UserDefaults persistence needed).
|
||||
// Per-provider carousel position: provider -> selected profile row id. Each
|
||||
// provider has its own profile carousel, so selection is tracked per provider.
|
||||
@State private var selectedProfileByProvider: [String: String] = [:]
|
||||
|
||||
// MARK: - Screen cap
|
||||
|
||||
@@ -164,6 +169,9 @@ struct BarMenuView: View {
|
||||
viewModel.onOpen()
|
||||
// Disarm quit on every popover open so a stale armed state never persists.
|
||||
quitArmed = false
|
||||
// Reset each provider's carousel to its first profile on every open — KISS,
|
||||
// no persistence needed.
|
||||
selectedProfileByProvider = [:]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -218,7 +226,7 @@ struct BarMenuView: View {
|
||||
/// present, preserving the single "Accounts" header for a CLIProxy-only setup.
|
||||
@ViewBuilder private var accountsSection: some View {
|
||||
let parts = BarFormatting.partitionSubscriptions(viewModel.rows)
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
if let error = viewModel.lastError {
|
||||
ErrorBanner(message: error)
|
||||
}
|
||||
@@ -234,9 +242,62 @@ struct BarMenuView: View {
|
||||
BarRowView(row: row, viewModel: viewModel)
|
||||
}
|
||||
} else {
|
||||
// Per-provider profile carousels: group subscription rows by provider, and
|
||||
// render each provider as its OWN horizontally-paged carousel of PROFILE
|
||||
// cards — one profile visible at a time, swipe left/right between that
|
||||
// provider's profiles, page dots indicate count. Provider sections stack
|
||||
// vertically. A provider with a single profile shows just its card (no
|
||||
// carousel, no dots).
|
||||
let groups = Dictionary(
|
||||
grouping: orderedSubscriptions(parts.subscriptions), by: { $0.provider })
|
||||
let providers = groups.keys.sorted() // stable: "claude-code" < "codex"
|
||||
let multiProvider = providers.count > 1
|
||||
subscriptionsHeader(parts.subscriptions)
|
||||
ForEach(orderedSubscriptions(parts.subscriptions)) { row in
|
||||
BarSubscriptionCard(row: row, onRefresh: { viewModel.forceRefresh() })
|
||||
ForEach(providers, id: \.self) { prov in
|
||||
let rows = groups[prov] ?? []
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
// Provider caption to delineate sections when more than one provider.
|
||||
if multiProvider {
|
||||
Text(BarFormatting.providerLabel(prov))
|
||||
.font(.system(size: 10, weight: .semibold))
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
if rows.count <= 1 {
|
||||
// Single profile — render the card directly, no carousel.
|
||||
if let row = rows.first {
|
||||
BarSubscriptionCard(
|
||||
row: row, isParked: row.paused,
|
||||
onRefresh: { viewModel.forceRefresh() })
|
||||
}
|
||||
} else {
|
||||
ScrollView(.horizontal, showsIndicators: false) {
|
||||
HStack(spacing: 0) {
|
||||
ForEach(rows) { row in
|
||||
BarSubscriptionCard(
|
||||
row: row, isParked: row.paused,
|
||||
onRefresh: { viewModel.forceRefresh() })
|
||||
// 340 leaves 10px side padding inside the 360-wide popover.
|
||||
.frame(width: 340, alignment: .leading)
|
||||
.padding(.horizontal, 2)
|
||||
.id(row.id)
|
||||
}
|
||||
}
|
||||
// scrollTargetLayout on the HStack (the paging container) snaps to
|
||||
// each profile card on .paging behavior.
|
||||
.scrollTargetLayout()
|
||||
}
|
||||
.scrollTargetBehavior(.paging)
|
||||
.scrollPosition(id: Binding(
|
||||
get: { selectedProfileByProvider[prov] ?? rows.first?.id },
|
||||
set: { selectedProfileByProvider[prov] = $0 ?? rows.first?.id }))
|
||||
.frame(height: carouselHeight(rows))
|
||||
// Page controls — clickable prev/next arrows + dots so the carousel
|
||||
// is navigable by MOUSE (click), not just by trackpad swipe.
|
||||
// Programmatically setting the scrollPosition binding pages the
|
||||
// ScrollView; withAnimation gives the same feel as a swipe.
|
||||
carouselControls(prov: prov, rows: rows)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -274,11 +335,14 @@ struct BarMenuView: View {
|
||||
}
|
||||
}
|
||||
|
||||
/// Order subscription cards by tightest binding window ascending (closest to
|
||||
/// empty on top). Rows with no binding window (error/reauth) sink to the bottom
|
||||
/// so the actionable quota always leads.
|
||||
/// Order subscription cards so the default/base account leads its provider
|
||||
/// carousel (it is the account the user runs by default), then by tightest
|
||||
/// binding window ascending (closest to empty next). Rows with no binding
|
||||
/// window (error/reauth) sink to the bottom so actionable quota leads.
|
||||
private func orderedSubscriptions(_ subs: [BarSummaryRow]) -> [BarSummaryRow] {
|
||||
subs.sorted { a, b in
|
||||
// Default account first within its provider group.
|
||||
if a.isDefault != b.isDefault { return a.isDefault }
|
||||
let ra = BarQuotaGauge.selectBindingWindow(a.quotaWindows ?? [])?.remainingPercent
|
||||
let rb = BarQuotaGauge.selectBindingWindow(b.quotaWindows ?? [])?.remainingPercent
|
||||
switch (ra, rb) {
|
||||
@@ -295,6 +359,64 @@ struct BarMenuView: View {
|
||||
}
|
||||
}
|
||||
|
||||
/// Prev/next arrows + clickable dots for a provider's profile carousel, so it
|
||||
/// is navigable by MOUSE (click), not only by trackpad swipe. Selecting a page
|
||||
/// sets the scrollPosition binding, which scrolls the ScrollView to that card.
|
||||
@ViewBuilder private func carouselControls(prov: String, rows: [BarSummaryRow]) -> some View {
|
||||
let currentId = selectedProfileByProvider[prov] ?? rows.first?.id
|
||||
let curIdx = rows.firstIndex(where: { $0.id == currentId }) ?? 0
|
||||
HStack(spacing: 7) {
|
||||
pageArrow(systemName: "chevron.left", enabled: curIdx > 0) {
|
||||
if curIdx > 0 { selectPage(prov, rows[curIdx - 1].id) }
|
||||
}
|
||||
ForEach(rows) { row in
|
||||
Circle()
|
||||
.fill(currentId == row.id ? theme.subscription : Color.secondary.opacity(0.3))
|
||||
.frame(width: 6, height: 6)
|
||||
.padding(4) // larger mouse hit target than the 6pt dot
|
||||
.contentShape(Rectangle())
|
||||
.onTapGesture { selectPage(prov, row.id) }
|
||||
}
|
||||
pageArrow(systemName: "chevron.right", enabled: curIdx < rows.count - 1) {
|
||||
if curIdx < rows.count - 1 { selectPage(prov, rows[curIdx + 1].id) }
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .center)
|
||||
}
|
||||
|
||||
/// Animate the carousel to the given profile card (mouse click or dot tap).
|
||||
private func selectPage(_ prov: String, _ id: String) {
|
||||
withAnimation(.easeInOut(duration: 0.2)) {
|
||||
selectedProfileByProvider[prov] = id
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder private func pageArrow(
|
||||
systemName: String, enabled: Bool, action: @escaping () -> Void
|
||||
) -> some View {
|
||||
Button(action: action) {
|
||||
Image(systemName: systemName)
|
||||
.font(.system(size: 9, weight: .bold))
|
||||
.frame(width: 16, height: 16)
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.disabled(!enabled)
|
||||
.opacity(enabled ? 0.7 : 0.25)
|
||||
}
|
||||
|
||||
/// Height of the tallest single card in a carousel — only one card is visible
|
||||
/// at a time, so the paged frame is sized to fit it WITHOUT reserving blank
|
||||
/// space beneath. Card = vertical padding (16) + title row (~22) + one bar per
|
||||
/// quota window (~20) + an optional stale footnote (~16); a parked/reauth card
|
||||
/// is just the title row plus a one-line status.
|
||||
private func carouselHeight(_ rows: [BarSummaryRow]) -> CGFloat {
|
||||
let maxWindows = rows.map { $0.quotaWindows?.count ?? 0 }.max() ?? 0
|
||||
if maxWindows == 0 { return 60 }
|
||||
let hasFootnote = rows.contains { $0.staleAsOf != nil }
|
||||
return 40 + CGFloat(maxWindows) * 20 + (hasFootnote ? 16 : 0)
|
||||
}
|
||||
|
||||
private var header: some View {
|
||||
HStack(spacing: 8) {
|
||||
Image(nsImage: MenuBarIcon.headerImage())
|
||||
|
||||
@@ -13,6 +13,9 @@ import CCSBarCore
|
||||
struct BarSubscriptionCard: View {
|
||||
@Environment(\.barTheme) private var theme
|
||||
let row: BarSummaryRow
|
||||
/// When true this profile is parked (not the active/default profile for the
|
||||
/// surface). The card is dimmed to 50% opacity to signal it is not live.
|
||||
var isParked: Bool = false
|
||||
/// Injected clock — defaults to live Date() in production, pinned in previews
|
||||
/// and tests so countdown math is deterministic.
|
||||
var now: Date = Date()
|
||||
@@ -44,20 +47,30 @@ struct BarSubscriptionCard: View {
|
||||
.background(
|
||||
theme.cardSurface,
|
||||
in: RoundedRectangle(cornerRadius: 9))
|
||||
// Dim parked (non-active) profiles so the active profile clearly dominates.
|
||||
.opacity(isParked ? 0.5 : 1.0)
|
||||
}
|
||||
|
||||
// MARK: Title row
|
||||
|
||||
/// Health dot + product name + reauth chip + tier chip. No pause toggle —
|
||||
/// subscriptions are not routable pool accounts.
|
||||
/// Health dot + profile name (or provider label for legacy rows) + surface chip
|
||||
/// + reauth chip + tier chip. No pause toggle — subscriptions are not routable
|
||||
/// pool accounts.
|
||||
private var titleRow: some View {
|
||||
HStack(spacing: 8) {
|
||||
Circle()
|
||||
.fill(healthColor)
|
||||
.frame(width: 8, height: 8)
|
||||
Text(BarFormatting.providerLabel(row.provider))
|
||||
// Default account shows the bare command ("ccsx"); a named profile shows its
|
||||
// name ("ck"). Falls back to the provider label for legacy rows.
|
||||
Text(BarFormatting.accountTitle(row))
|
||||
.font(.system(.body, design: .default).weight(.semibold))
|
||||
.lineLimit(1)
|
||||
// Tag: "default" for the base account, else the owning surface ("ccsx") so a
|
||||
// named profile reads as e.g. "ck · ccsx".
|
||||
if let tag = BarFormatting.accountTag(row) {
|
||||
Chip(tag, tint: theme.subscription.opacity(isParked ? 0.5 : 1))
|
||||
}
|
||||
if row.needsReauth {
|
||||
Chip("reauth", tint: theme.bandRed)
|
||||
}
|
||||
|
||||
@@ -1686,6 +1686,182 @@ do {
|
||||
check(!BarUpdateChecker.isNewer("1.7", than: "1.7.0"), "isNewer: fewer than 3 parts rejected")
|
||||
}
|
||||
|
||||
// MARK: Multi-profile fields decode (GH-1595)
|
||||
//
|
||||
// Three new optional wire keys: surface, profile, is_subscription.
|
||||
// Native rows carry all three; CLIProxy pool rows omit them — legacy decoders
|
||||
// must not fail and must yield nil for the absent fields.
|
||||
|
||||
let multiProfileJSON = """
|
||||
[
|
||||
{
|
||||
"account_id": "ccs:work",
|
||||
"provider": "claude-code",
|
||||
"surface": "ccs",
|
||||
"profile": "work",
|
||||
"is_subscription": true,
|
||||
"displayName": "work",
|
||||
"tier": "max",
|
||||
"paused": false,
|
||||
"quota_percentage": 62,
|
||||
"quotaStatus": "ok",
|
||||
"next_reset": "2026-06-24T01:00:00.000Z",
|
||||
"is_default": true,
|
||||
"last_activity_at": null,
|
||||
"today_cost": null,
|
||||
"health": "ok",
|
||||
"cached": false,
|
||||
"fetchedAt": "2026-06-23T20:40:00.000Z",
|
||||
"needsReauth": false
|
||||
},
|
||||
{
|
||||
"account_id": "ccsx:ck",
|
||||
"provider": "codex",
|
||||
"surface": "ccsx",
|
||||
"profile": "ck",
|
||||
"is_subscription": true,
|
||||
"displayName": "ck",
|
||||
"tier": "pro",
|
||||
"paused": true,
|
||||
"quota_percentage": 80,
|
||||
"quotaStatus": "ok",
|
||||
"next_reset": null,
|
||||
"is_default": false,
|
||||
"last_activity_at": null,
|
||||
"today_cost": null,
|
||||
"health": "ok",
|
||||
"cached": true,
|
||||
"fetchedAt": "2026-06-23T16:19:00.000Z",
|
||||
"needsReauth": false,
|
||||
"stale_as_of": "2026-06-23T16:19:00.000Z"
|
||||
},
|
||||
{
|
||||
"account_id": "alice@example.com",
|
||||
"provider": "agy",
|
||||
"displayName": "Alice (Ultra)",
|
||||
"tier": "ultra",
|
||||
"paused": false,
|
||||
"quota_percentage": 70,
|
||||
"quotaStatus": "ok",
|
||||
"next_reset": null,
|
||||
"is_default": false,
|
||||
"last_activity_at": null,
|
||||
"today_cost": null,
|
||||
"health": "ok",
|
||||
"cached": true,
|
||||
"fetchedAt": "2026-06-23T20:00:00.000Z",
|
||||
"needsReauth": false
|
||||
}
|
||||
]
|
||||
"""
|
||||
|
||||
do {
|
||||
let rows = try JSONDecoder().decode([BarSummaryRow].self, from: Data(multiProfileJSON.utf8))
|
||||
check(rows.count == 3, "mp: decodes 3 rows (2 native + 1 legacy CLIProxy)")
|
||||
|
||||
// (MP1) Native Claude active row
|
||||
let claudeRow = rows[0]
|
||||
check(claudeRow.accountId == "ccs:work", "mp: native Claude account_id = 'ccs:work'")
|
||||
check(claudeRow.surface == "ccs", "mp: native Claude surface = 'ccs'")
|
||||
check(claudeRow.profile == "work", "mp: native Claude profile = 'work'")
|
||||
check(claudeRow.isSubscription == true, "mp: native Claude is_subscription = true")
|
||||
check(claudeRow.paused == false, "mp: active Claude profile is not paused")
|
||||
|
||||
// (MP2) Parked Codex row
|
||||
let codexRow = rows[1]
|
||||
check(codexRow.accountId == "ccsx:ck", "mp: parked Codex account_id = 'ccsx:ck'")
|
||||
check(codexRow.surface == "ccsx", "mp: parked Codex surface = 'ccsx'")
|
||||
check(codexRow.profile == "ck", "mp: parked Codex profile = 'ck'")
|
||||
check(codexRow.isSubscription == true, "mp: parked Codex is_subscription = true")
|
||||
check(codexRow.paused == true, "mp: parked Codex row has paused = true")
|
||||
|
||||
// (MP3) Legacy CLIProxy row — new fields decode to nil without failure
|
||||
let legacyRow = rows[2]
|
||||
check(legacyRow.surface == nil, "mp: CLIProxy row surface decodes to nil (backward compat)")
|
||||
check(legacyRow.profile == nil, "mp: CLIProxy row profile decodes to nil (backward compat)")
|
||||
check(legacyRow.isSubscription == nil, "mp: CLIProxy row is_subscription decodes to nil")
|
||||
|
||||
// (MP4) isNativeSubscription uses is_subscription flag when present
|
||||
check(
|
||||
BarFormatting.isNativeSubscription(claudeRow),
|
||||
"mp: isNativeSubscription true when is_subscription=true (new flag path)")
|
||||
check(
|
||||
BarFormatting.isNativeSubscription(codexRow),
|
||||
"mp: isNativeSubscription true for parked Codex row (is_subscription=true)")
|
||||
check(
|
||||
!BarFormatting.isNativeSubscription(legacyRow),
|
||||
"mp: isNativeSubscription false for CLIProxy pool row (no is_subscription)")
|
||||
|
||||
// (MP5) Legacy rows without is_subscription use fallback heuristic
|
||||
let legacyClaudeRow = BarSummaryRow(accountId: "claude-code", provider: "claude-code")
|
||||
check(
|
||||
BarFormatting.isNativeSubscription(legacyClaudeRow),
|
||||
"mp: legacy claude-code row uses heuristic fallback -> is native")
|
||||
let legacyCodexRow = BarSummaryRow(accountId: "codex", provider: "codex")
|
||||
check(
|
||||
BarFormatting.isNativeSubscription(legacyCodexRow),
|
||||
"mp: legacy codex row uses heuristic fallback -> is native")
|
||||
let poolCodexRow = BarSummaryRow(accountId: "pool-codex-oauth-1", provider: "codex")
|
||||
check(
|
||||
!BarFormatting.isNativeSubscription(poolCodexRow),
|
||||
"mp: CLIProxy codex pool row with no is_subscription -> not native (heuristic)")
|
||||
|
||||
// (MP6) partitionSubscriptions correctly splits with new flag
|
||||
let parts = BarFormatting.partitionSubscriptions(rows)
|
||||
check(parts.subscriptions.count == 2, "mp: partition yields 2 native subscriptions")
|
||||
check(parts.pool.count == 1, "mp: partition yields 1 CLIProxy pool row")
|
||||
check(
|
||||
parts.subscriptions.map { $0.accountId } == ["ccs:work", "ccsx:ck"],
|
||||
"mp: subscriptions keep backend order")
|
||||
|
||||
// (MP7) surfaceProfileLabel helper
|
||||
check(
|
||||
BarFormatting.surfaceProfileLabel(claudeRow) == "ccs · work",
|
||||
"mp: surfaceProfileLabel for ccs:work -> 'ccs · work'")
|
||||
check(
|
||||
BarFormatting.surfaceProfileLabel(codexRow) == "ccsx · ck",
|
||||
"mp: surfaceProfileLabel for ccsx:ck -> 'ccsx · ck'")
|
||||
check(
|
||||
BarFormatting.surfaceProfileLabel(legacyRow) == nil,
|
||||
"mp: surfaceProfileLabel for CLIProxy row (no profile) -> nil")
|
||||
|
||||
// (MP8) surfaceProfileLabel falls back to provider when surface is absent
|
||||
let noSurfaceRow = BarSummaryRow(
|
||||
accountId: "ccsx:personal", provider: "codex",
|
||||
surface: nil, profile: "personal", isSubscription: true)
|
||||
check(
|
||||
BarFormatting.surfaceProfileLabel(noSurfaceRow) == "codex · personal",
|
||||
"mp: surfaceProfileLabel falls back to provider when surface is nil")
|
||||
|
||||
// (MP9) base/default account vs named profile presentation
|
||||
let defaultCodexRow = BarSummaryRow(
|
||||
accountId: "ccsx:default", provider: "codex",
|
||||
surface: "ccsx", profile: "default", isSubscription: true)
|
||||
check(
|
||||
BarFormatting.isBaseAccount(defaultCodexRow),
|
||||
"mp: 'default' profile is the base account")
|
||||
check(
|
||||
!BarFormatting.isBaseAccount(codexRow),
|
||||
"mp: named profile 'ck' is not the base account")
|
||||
check(
|
||||
BarFormatting.accountTitle(defaultCodexRow) == "ccsx",
|
||||
"mp: accountTitle for default account -> 'ccsx' (bare command)")
|
||||
check(
|
||||
BarFormatting.accountTitle(codexRow) == "ck",
|
||||
"mp: accountTitle for named profile -> 'ck'")
|
||||
check(
|
||||
BarFormatting.accountTag(defaultCodexRow) == "default",
|
||||
"mp: accountTag for default account -> 'default'")
|
||||
check(
|
||||
BarFormatting.accountTag(codexRow) == "ccsx",
|
||||
"mp: accountTag for named profile -> 'ccsx' (surface)")
|
||||
check(
|
||||
BarFormatting.accountTag(legacyRow) == nil,
|
||||
"mp: accountTag for CLIProxy pool row -> nil")
|
||||
} catch {
|
||||
check(false, "mp: multi-profile JSON decode failed: \(error)")
|
||||
}
|
||||
|
||||
// cleanup
|
||||
try? FileManager.default.removeItem(atPath: tmp)
|
||||
|
||||
|
||||
@@ -165,11 +165,49 @@ public enum BarFormatting {
|
||||
/// Code or Codex plan) rather than a CLIProxy-managed OAuth pool account. Drives
|
||||
/// the "Subscriptions" grouping + badge so a user reads "this is MY plan quota",
|
||||
/// not one of the rotating pool credentials.
|
||||
///
|
||||
/// Prefers the explicit `is_subscription` flag from the server (multi-profile
|
||||
/// feature). Falls back to the legacy heuristic (`accountId == provider`) so
|
||||
/// old single-profile payloads still work without `is_subscription`.
|
||||
public static func isNativeSubscription(_ row: BarSummaryRow) -> Bool {
|
||||
(row.provider == "claude-code" && row.accountId == "claude-code")
|
||||
if let s = row.isSubscription { return s }
|
||||
// Legacy fallback for old payloads that omit is_subscription.
|
||||
return (row.provider == "claude-code" && row.accountId == "claude-code")
|
||||
|| (row.provider == "codex" && row.accountId == "codex")
|
||||
}
|
||||
|
||||
/// Surface + profile chip label for multi-profile cards, e.g. "ccs · work" or
|
||||
/// "ccsx · ck". Returns nil for CLIProxy pool rows that have no profile.
|
||||
public static func surfaceProfileLabel(_ row: BarSummaryRow) -> String? {
|
||||
guard let p = row.profile else { return nil }
|
||||
let s = row.surface ?? row.provider
|
||||
return "\(s) · \(p)"
|
||||
}
|
||||
|
||||
/// True when this row is the surface's default/base account — the bare login
|
||||
/// (e.g. `ccsx` => ~/.codex), as opposed to a named `ccsx <profile>` profile.
|
||||
/// The server names this account "default".
|
||||
public static func isBaseAccount(_ row: BarSummaryRow) -> Bool {
|
||||
row.profile == "default"
|
||||
}
|
||||
|
||||
/// Primary card title. For the default/base account it is the bare command the
|
||||
/// user actually runs ("ccsx" / "ccs"); for a named profile it is the profile
|
||||
/// name ("ck"). Falls back to the provider product label for legacy rows.
|
||||
public static func accountTitle(_ row: BarSummaryRow) -> String {
|
||||
if isBaseAccount(row) { return row.surface ?? providerLabel(row.provider) }
|
||||
return row.profile ?? providerLabel(row.provider)
|
||||
}
|
||||
|
||||
/// Secondary chip beside the title. The default/base account is tagged
|
||||
/// "default"; a named profile is tagged with the surface that owns it
|
||||
/// ("ccs"/"ccsx") so "ck" reads as a ccsx profile. Nil for pool rows.
|
||||
public static func accountTag(_ row: BarSummaryRow) -> String? {
|
||||
guard row.profile != nil else { return nil }
|
||||
if isBaseAccount(row) { return "default" }
|
||||
return row.surface ?? row.provider
|
||||
}
|
||||
|
||||
/// Friendly product label for a provider key. Native subscription keys read as
|
||||
/// products ("Claude Code", "Codex"); any other provider passes through verbatim
|
||||
/// (so "agy"/"ghcp"/"kiro" keep their established short chip text).
|
||||
|
||||
@@ -65,6 +65,16 @@ public struct BarSummaryRow: Codable, Sendable, Identifiable, Equatable {
|
||||
public let cached: Bool
|
||||
public let fetchedAt: String?
|
||||
public let needsReauth: Bool
|
||||
/// Multi-profile: which CCS surface owns this profile ("ccs" = Claude via
|
||||
/// `ccs auth`, "ccsx" = Codex via `ccsx auth`). nil on CLIProxy pool rows.
|
||||
public let surface: String?
|
||||
/// Multi-profile: the profile name (e.g. "work", "ck", "personal"). nil on
|
||||
/// CLIProxy pool rows.
|
||||
public let profile: String?
|
||||
/// Explicit native-subscription flag. true on all native rows (Claude/Codex
|
||||
/// own subscription), nil/false on CLIProxy pool rows. Replaces the brittle
|
||||
/// `accountId == "claude-code"` heuristic.
|
||||
public let isSubscription: Bool?
|
||||
/// Native-only per-window quota breakdown (Claude: 5h/week/opus/sonnet,
|
||||
/// Codex: 5h/week). nil for CLIProxy pool rows, which omit "quota_windows"
|
||||
/// entirely — so legacy payloads decode unchanged (backward compatible).
|
||||
@@ -93,6 +103,9 @@ public struct BarSummaryRow: Codable, Sendable, Identifiable, Equatable {
|
||||
case cached
|
||||
case fetchedAt
|
||||
case needsReauth
|
||||
case surface
|
||||
case profile
|
||||
case isSubscription = "is_subscription"
|
||||
case quotaWindows = "quota_windows"
|
||||
case staleAsOf = "stale_as_of"
|
||||
}
|
||||
@@ -113,6 +126,9 @@ public struct BarSummaryRow: Codable, Sendable, Identifiable, Equatable {
|
||||
cached: Bool = false,
|
||||
fetchedAt: String? = nil,
|
||||
needsReauth: Bool = false,
|
||||
surface: String? = nil,
|
||||
profile: String? = nil,
|
||||
isSubscription: Bool? = nil,
|
||||
quotaWindows: [QuotaWindowDetail]? = nil,
|
||||
staleAsOf: String? = nil
|
||||
) {
|
||||
@@ -131,6 +147,9 @@ public struct BarSummaryRow: Codable, Sendable, Identifiable, Equatable {
|
||||
self.cached = cached
|
||||
self.fetchedAt = fetchedAt
|
||||
self.needsReauth = needsReauth
|
||||
self.surface = surface
|
||||
self.profile = profile
|
||||
self.isSubscription = isSubscription
|
||||
self.quotaWindows = quotaWindows
|
||||
self.staleAsOf = staleAsOf
|
||||
}
|
||||
@@ -156,6 +175,9 @@ public struct BarSummaryRow: Codable, Sendable, Identifiable, Equatable {
|
||||
cached = try c.decode(Bool.self, forKey: .cached)
|
||||
fetchedAt = try c.decodeIfPresent(String.self, forKey: .fetchedAt)
|
||||
needsReauth = try c.decode(Bool.self, forKey: .needsReauth)
|
||||
surface = try c.decodeIfPresent(String.self, forKey: .surface)
|
||||
profile = try c.decodeIfPresent(String.self, forKey: .profile)
|
||||
isSubscription = try c.decodeIfPresent(Bool.self, forKey: .isSubscription)
|
||||
quotaWindows = try c.decodeIfPresent([QuotaWindowDetail].self, forKey: .quotaWindows)
|
||||
staleAsOf = try c.decodeIfPresent(String.self, forKey: .staleAsOf)
|
||||
}
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
1.8.1
|
||||
1.9.0
|
||||
@@ -96,6 +96,22 @@ export interface BarSummaryRow {
|
||||
fetchedAt: string;
|
||||
/** True if account token is expired and needs re-authentication */
|
||||
needsReauth: boolean;
|
||||
/**
|
||||
* Native subscription surface: "ccs" (Claude Code) or "ccsx" (Codex).
|
||||
* Present ONLY on native subscription rows; omitted on CLIProxy pool rows.
|
||||
*/
|
||||
surface?: string;
|
||||
/**
|
||||
* Native profile name (e.g. "work", "ck", "personal").
|
||||
* Present ONLY on native subscription rows; omitted on CLIProxy pool rows.
|
||||
*/
|
||||
profile?: string;
|
||||
/**
|
||||
* Explicit native-subscription flag. true on all native rows; omitted on
|
||||
* CLIProxy pool rows (decodes to false/nil). Replaces the brittle
|
||||
* accountId == "claude-code" heuristic in Swift.
|
||||
*/
|
||||
is_subscription?: boolean;
|
||||
/**
|
||||
* Native-only per-window quota breakdown (Claude: 5h/week/opus/sonnet,
|
||||
* Codex: 5h/week). CLIProxy rows OMIT this field so existing decode/encode
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -1220,3 +1220,151 @@ describe('/summary native subscription rows', () => {
|
||||
expect(body[0].provider).toBe('agy');
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// GH-1595: wire contract — native rows carry surface/profile/is_subscription;
|
||||
// CLIProxy pool rows OMIT all three fields.
|
||||
// ============================================================================
|
||||
|
||||
describe('/summary wire contract: surface/profile/is_subscription fields (GH-1595)', () => {
|
||||
/**
|
||||
* Extended wire row type that includes the new optional fields.
|
||||
* BarSummaryRow in the test file omits them; extend locally here.
|
||||
*/
|
||||
interface WireRow extends BarSummaryRow {
|
||||
surface?: string;
|
||||
profile?: string;
|
||||
is_subscription?: boolean;
|
||||
}
|
||||
|
||||
function makeNativeRow(
|
||||
surface: 'ccs' | 'ccsx',
|
||||
profile: string,
|
||||
paused = false
|
||||
): BarSummaryRow {
|
||||
return {
|
||||
account_id: `${surface}:${profile}`,
|
||||
provider: surface === 'ccs' ? 'claude-code' : 'codex',
|
||||
displayName: profile,
|
||||
tier: 'pro',
|
||||
paused,
|
||||
quota_percentage: 55,
|
||||
quotaStatus: 'ok',
|
||||
next_reset: null,
|
||||
is_default: !paused,
|
||||
last_activity_at: null,
|
||||
today_cost: null,
|
||||
health: 'ok',
|
||||
cached: false,
|
||||
fetchedAt: '2026-06-23T20:00:00.000Z',
|
||||
needsReauth: false,
|
||||
// The TS interface now has these optional fields — set them explicitly.
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
...(({ surface, profile, is_subscription: true }) as any),
|
||||
};
|
||||
}
|
||||
|
||||
async function buildWireRouter(nativeRows: BarSummaryRow[]) {
|
||||
const { createBarRouter, resetForceFreshDebounce: resetDebounce } = await import(
|
||||
'../../../src/web-server/routes/bar-routes'
|
||||
);
|
||||
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
|
||||
const cliproxyAccount = makeAccountInfo({ id: 'pool@example.com', provider: 'agy' });
|
||||
|
||||
const router = createBarRouter({
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
getAllAccountsSummary: () => ({ agy: [cliproxyAccount] }) as any,
|
||||
getCachedQuota: () => makeQuotaResult(),
|
||||
setCachedQuota: () => {},
|
||||
invalidateQuotaCache: () => {},
|
||||
fetchAccountQuota: async () => makeQuotaResult(),
|
||||
getTodayCostByAccount: () => ({}),
|
||||
loadCliproxyDetails: async () => [],
|
||||
loadDailyUsage: async () => [],
|
||||
loadHourlyUsage: async () => [],
|
||||
runHealthChecks: async () => makeHealthReport(),
|
||||
getNativeAccountRows: async () => nativeRows,
|
||||
});
|
||||
|
||||
app.use('/api/bar', router);
|
||||
const srv = await new Promise<Server>((resolve, reject) => {
|
||||
const instance = app.listen(0, '127.0.0.1');
|
||||
instance.once('error', reject);
|
||||
instance.once('listening', () => resolve(instance));
|
||||
});
|
||||
const addr = srv.address();
|
||||
if (!addr || typeof addr === 'string') throw new Error('No server address');
|
||||
resetDebounce();
|
||||
return { srv, url: `http://127.0.0.1:${(addr as { port: number }).port}` };
|
||||
}
|
||||
|
||||
it('native rows include surface, profile, is_subscription=true in the JSON response', async () => {
|
||||
const { srv, url } = await buildWireRouter([
|
||||
makeNativeRow('ccs', 'work', false),
|
||||
makeNativeRow('ccsx', 'personal', false),
|
||||
]);
|
||||
const { body } = await getJson<WireRow[]>(url, '/api/bar/summary');
|
||||
await new Promise<void>((resolve) => srv.close(() => resolve()));
|
||||
|
||||
const claudeRow = body.find((r) => r.provider === 'claude-code');
|
||||
expect(claudeRow).toBeDefined();
|
||||
expect(claudeRow?.surface).toBe('ccs');
|
||||
expect(claudeRow?.profile).toBe('work');
|
||||
expect(claudeRow?.is_subscription).toBe(true);
|
||||
expect(claudeRow?.account_id).toBe('ccs:work');
|
||||
|
||||
const codexRow = body.find((r) => r.provider === 'codex');
|
||||
expect(codexRow).toBeDefined();
|
||||
expect(codexRow?.surface).toBe('ccsx');
|
||||
expect(codexRow?.profile).toBe('personal');
|
||||
expect(codexRow?.is_subscription).toBe(true);
|
||||
expect(codexRow?.account_id).toBe('ccsx:personal');
|
||||
});
|
||||
|
||||
it('CLIProxy pool rows OMIT surface, profile, is_subscription', async () => {
|
||||
const { srv, url } = await buildWireRouter([
|
||||
makeNativeRow('ccs', 'work', false),
|
||||
]);
|
||||
const { body } = await getJson<WireRow[]>(url, '/api/bar/summary');
|
||||
await new Promise<void>((resolve) => srv.close(() => resolve()));
|
||||
|
||||
// The CLIProxy row (provider 'agy') should NOT have the new fields.
|
||||
const cliproxyRow = body.find((r) => r.provider === 'agy');
|
||||
expect(cliproxyRow).toBeDefined();
|
||||
expect(cliproxyRow?.surface).toBeUndefined();
|
||||
expect(cliproxyRow?.profile).toBeUndefined();
|
||||
expect(cliproxyRow?.is_subscription).toBeUndefined();
|
||||
});
|
||||
|
||||
it('parked native row (paused:true) is present with is_subscription=true and paused=true', async () => {
|
||||
const { srv, url } = await buildWireRouter([
|
||||
makeNativeRow('ccsx', 'ck', true), // parked Codex profile
|
||||
]);
|
||||
const { body } = await getJson<WireRow[]>(url, '/api/bar/summary');
|
||||
await new Promise<void>((resolve) => srv.close(() => resolve()));
|
||||
|
||||
const parked = body.find((r) => r.profile === 'ck');
|
||||
expect(parked).toBeDefined();
|
||||
expect(parked?.paused).toBe(true);
|
||||
expect(parked?.is_subscription).toBe(true);
|
||||
expect(parked?.surface).toBe('ccsx');
|
||||
});
|
||||
|
||||
it('account_id on native rows uses the <surface>:<profile> scheme', async () => {
|
||||
const { srv, url } = await buildWireRouter([
|
||||
makeNativeRow('ccs', 'ck', false),
|
||||
makeNativeRow('ccsx', 'ck', true),
|
||||
]);
|
||||
const { body } = await getJson<WireRow[]>(url, '/api/bar/summary');
|
||||
await new Promise<void>((resolve) => srv.close(() => resolve()));
|
||||
|
||||
const claudeRow = body.find((r) => r.surface === 'ccs');
|
||||
expect(claudeRow?.account_id).toBe('ccs:ck');
|
||||
|
||||
const codexRow = body.find((r) => r.surface === 'ccsx');
|
||||
expect(codexRow?.account_id).toBe('ccsx:ck');
|
||||
});
|
||||
});
|
||||
@@ -775,3 +775,614 @@ describe('getCachedNativeAccountRows (instant, no-fetch fallback)', () => {
|
||||
expect(getCachedNativeAccountRows()).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// Multi-profile path tests (GH-1595)
|
||||
//
|
||||
// These tests inject listClaudeProfiles / listCodexProfiles / defaultClaudeProfile
|
||||
// / defaultCodexProfile so the production profile-enumeration path is exercised
|
||||
// without touching real ~/.ccs or any Keychain. The readClaudeCredentialsForProfile
|
||||
// and readCodexNativeAuth seams prevent fs access.
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Build a NativeQuotaDeps for the multi-profile path.
|
||||
*
|
||||
* - claudeProfiles: profile names for the Claude surface (ccs)
|
||||
* - codexProfiles: profile names for the Codex surface (ccsx)
|
||||
* - claudeDefault / codexDefault: the active profile per surface (paused:false)
|
||||
* - credsForProfile: map from profile name to credentials (null = parked)
|
||||
* - claudeFetch: network fetcher for Claude (all profiles share one implementation)
|
||||
* - codexNativeAuth: map from profile name to {accessToken, accountId}
|
||||
* - codexNetworkFetch: network fetcher for Codex (all profiles share one impl)
|
||||
*/
|
||||
function makeMultiProfileDeps(opts: {
|
||||
clock: { now: number };
|
||||
claudeProfiles: string[];
|
||||
codexProfiles: string[];
|
||||
claudeDefault?: string | null;
|
||||
codexDefault?: string | null;
|
||||
credsForProfile?: (profile: string) => ClaudeNativeCredentials | null;
|
||||
claudeFetch?: (token: string, accountId?: string) => Promise<ClaudeQuotaResult>;
|
||||
codexNativeAuth?: (profile: string) => { accessToken: string; accountId: string } | null;
|
||||
codexNetworkFetch?: (accountId: string) => Promise<CodexQuotaResult>;
|
||||
codexLocalFallback?: () => Promise<CodexLocalQuota | null>;
|
||||
}): NativeQuotaDeps & {
|
||||
claudeFetchCount: () => number;
|
||||
codexNetworkCount: () => number;
|
||||
} {
|
||||
let claudeFetches = 0;
|
||||
let codexNetworkFetches = 0;
|
||||
|
||||
const {
|
||||
clock,
|
||||
claudeProfiles,
|
||||
codexProfiles,
|
||||
claudeDefault = null,
|
||||
codexDefault = null,
|
||||
credsForProfile = () => null,
|
||||
claudeFetch = async () => successQuota(),
|
||||
codexNativeAuth = () => null,
|
||||
codexNetworkFetch = async () => codexSuccessQuota(),
|
||||
codexLocalFallback = async () => null,
|
||||
} = opts;
|
||||
|
||||
return {
|
||||
// Enumeration seams
|
||||
listClaudeProfiles: () => claudeProfiles,
|
||||
listCodexProfiles: () => codexProfiles,
|
||||
defaultClaudeProfile: () => claudeDefault,
|
||||
defaultCodexProfile: () => codexDefault,
|
||||
// Credential seams (file-only, no keychain)
|
||||
readClaudeCredentialsForProfile: credsForProfile,
|
||||
readCodexNativeAuth: codexNativeAuth,
|
||||
// Fetch seams
|
||||
fetchClaudeQuota: async (token: string, accountId?: string) => {
|
||||
claudeFetches += 1;
|
||||
return claudeFetch(token, accountId);
|
||||
},
|
||||
fetchCodexNetworkQuota: async (accountId: string) => {
|
||||
codexNetworkFetches += 1;
|
||||
return codexNetworkFetch(accountId);
|
||||
},
|
||||
getCodexQuota: codexLocalFallback,
|
||||
// Disable legacy single-profile paths
|
||||
readCredentials: () => null,
|
||||
getDefaultCodexAccountId: () => null,
|
||||
// Clock + sleep seams
|
||||
now: () => clock.now,
|
||||
sleep: async () => {},
|
||||
// Counters
|
||||
claudeFetchCount: () => claudeFetches,
|
||||
codexNetworkCount: () => codexNetworkFetches,
|
||||
};
|
||||
}
|
||||
|
||||
describe('multi-profile: account_id and wire fields', () => {
|
||||
it('Claude profile rows carry surface="ccs", account_id="ccs:<p>", is_subscription=true', async () => {
|
||||
const clock = { now: 1_000_000 };
|
||||
const deps = makeMultiProfileDeps({
|
||||
clock,
|
||||
claudeProfiles: ['work', 'ck'],
|
||||
codexProfiles: [],
|
||||
claudeDefault: 'work',
|
||||
// 'work' has creds; 'ck' does not (parked)
|
||||
credsForProfile: (p) => (p === 'work' ? maxCreds() : null),
|
||||
claudeFetch: async () => successQuota(),
|
||||
});
|
||||
|
||||
const rows = await getNativeAccountRows(deps);
|
||||
expect(rows.length).toBe(2);
|
||||
|
||||
const work = rows.find((r) => r.profile === 'work');
|
||||
expect(work).toBeDefined();
|
||||
expect(work?.account_id).toBe('ccs:work');
|
||||
expect(work?.surface).toBe('ccs');
|
||||
expect(work?.is_subscription).toBe(true);
|
||||
expect(work?.provider).toBe('claude-code');
|
||||
|
||||
const ck = rows.find((r) => r.profile === 'ck');
|
||||
expect(ck).toBeDefined();
|
||||
expect(ck?.account_id).toBe('ccs:ck');
|
||||
expect(ck?.surface).toBe('ccs');
|
||||
expect(ck?.is_subscription).toBe(true);
|
||||
});
|
||||
|
||||
it('Codex profile rows carry surface="ccsx", account_id="ccsx:<p>", is_subscription=true', async () => {
|
||||
const clock = { now: 1_000_000 };
|
||||
const deps = makeMultiProfileDeps({
|
||||
clock,
|
||||
claudeProfiles: [],
|
||||
codexProfiles: ['personal', 'ck'],
|
||||
codexDefault: 'personal',
|
||||
codexNativeAuth: (p) => ({ accessToken: `tok-${p}`, accountId: `id-${p}` }),
|
||||
codexNetworkFetch: async () => codexSuccessQuota(),
|
||||
});
|
||||
|
||||
const rows = await getNativeAccountRows(deps);
|
||||
expect(rows.length).toBe(2);
|
||||
|
||||
const personal = rows.find((r) => r.profile === 'personal');
|
||||
expect(personal?.account_id).toBe('ccsx:personal');
|
||||
expect(personal?.surface).toBe('ccsx');
|
||||
expect(personal?.is_subscription).toBe(true);
|
||||
expect(personal?.provider).toBe('codex');
|
||||
|
||||
const ck = rows.find((r) => r.profile === 'ck');
|
||||
expect(ck?.account_id).toBe('ccsx:ck');
|
||||
expect(ck?.surface).toBe('ccsx');
|
||||
expect(ck?.is_subscription).toBe(true);
|
||||
});
|
||||
|
||||
it('paused reflects liveness (creds present), NOT default-ness; is_default marks the default independently', async () => {
|
||||
const clock = { now: 1_000_000 };
|
||||
const deps = makeMultiProfileDeps({
|
||||
clock,
|
||||
// Claude: work = default + creds (live); ck = non-default + NO creds (parked).
|
||||
claudeProfiles: ['work', 'ck'],
|
||||
// Codex: personal = default + creds (live); ck = NON-default + creds (live).
|
||||
codexProfiles: ['personal', 'ck'],
|
||||
claudeDefault: 'work',
|
||||
codexDefault: 'personal',
|
||||
credsForProfile: (p) => (p === 'work' ? maxCreds() : null),
|
||||
claudeFetch: async () => successQuota(),
|
||||
codexNativeAuth: (p) => ({ accessToken: `tok-${p}`, accountId: `id-${p}` }),
|
||||
codexNetworkFetch: async () => codexSuccessQuota(),
|
||||
});
|
||||
|
||||
const rows = await getNativeAccountRows(deps);
|
||||
|
||||
// Claude work: default + creds -> live, not dimmed.
|
||||
const claudeWork = rows.find((r) => r.surface === 'ccs' && r.profile === 'work');
|
||||
expect(claudeWork?.paused).toBe(false);
|
||||
expect(claudeWork?.is_default).toBe(true);
|
||||
|
||||
// Claude ck: non-default + NO creds -> parked/dimmed.
|
||||
const claudeCk = rows.find((r) => r.surface === 'ccs' && r.profile === 'ck');
|
||||
expect(claudeCk?.paused).toBe(true);
|
||||
expect(claudeCk?.is_default).toBe(false);
|
||||
|
||||
// Codex personal: default + creds -> live.
|
||||
const codexPersonal = rows.find((r) => r.surface === 'ccsx' && r.profile === 'personal');
|
||||
expect(codexPersonal?.paused).toBe(false);
|
||||
expect(codexPersonal?.is_default).toBe(true);
|
||||
|
||||
// Codex ck: NON-default but HAS creds -> LIVE, NOT dimmed. This is the key
|
||||
// correctness guarantee: a valid isolated subscription is never dimmed just
|
||||
// because it is not the surface default.
|
||||
const codexCk = rows.find((r) => r.surface === 'ccsx' && r.profile === 'ck');
|
||||
expect(codexCk?.paused).toBe(false);
|
||||
expect(codexCk?.is_default).toBe(false);
|
||||
});
|
||||
|
||||
it('N Claude + M Codex profiles produce N+M rows', async () => {
|
||||
const clock = { now: 1_000_000 };
|
||||
const claudeProfiles = ['work', 'ck', 'personal'];
|
||||
const codexProfiles = ['personal', 'ck'];
|
||||
const deps = makeMultiProfileDeps({
|
||||
clock,
|
||||
claudeProfiles,
|
||||
codexProfiles,
|
||||
claudeDefault: 'work',
|
||||
codexDefault: 'personal',
|
||||
credsForProfile: () => maxCreds(),
|
||||
codexNativeAuth: (p) => ({ accessToken: `tok-${p}`, accountId: `id-${p}` }),
|
||||
});
|
||||
|
||||
const rows = await getNativeAccountRows(deps);
|
||||
expect(rows.length).toBe(claudeProfiles.length + codexProfiles.length);
|
||||
});
|
||||
|
||||
it('rows are sorted by (surface, profile)', async () => {
|
||||
const clock = { now: 1_000_000 };
|
||||
const deps = makeMultiProfileDeps({
|
||||
clock,
|
||||
claudeProfiles: ['work', 'ck'],
|
||||
codexProfiles: ['ck', 'personal'],
|
||||
claudeDefault: 'work',
|
||||
codexDefault: 'personal',
|
||||
credsForProfile: () => maxCreds(),
|
||||
codexNativeAuth: (p) => ({ accessToken: `tok-${p}`, accountId: `id-${p}` }),
|
||||
});
|
||||
|
||||
const rows = await getNativeAccountRows(deps);
|
||||
const keys = rows.map((r) => `${r.surface}:${r.profile}`);
|
||||
// ccs:ck < ccs:work < ccsx:ck < ccsx:personal
|
||||
expect(keys).toEqual(['ccs:ck', 'ccs:work', 'ccsx:ck', 'ccsx:personal']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('multi-profile: Claude file-only reader', () => {
|
||||
it('profile with .credentials.json present -> live fetch row (paused:false when default)', async () => {
|
||||
const clock = { now: 1_000_000 };
|
||||
const deps = makeMultiProfileDeps({
|
||||
clock,
|
||||
claudeProfiles: ['work'],
|
||||
codexProfiles: [],
|
||||
claudeDefault: 'work',
|
||||
credsForProfile: (p) => (p === 'work' ? maxCreds() : null),
|
||||
claudeFetch: async () => successQuota(),
|
||||
});
|
||||
|
||||
const rows = await getNativeAccountRows(deps);
|
||||
expect(rows.length).toBe(1);
|
||||
const row = rows[0];
|
||||
expect(row?.profile).toBe('work');
|
||||
expect(row?.quotaStatus).toBe('ok');
|
||||
expect(row?.needsReauth).toBe(false);
|
||||
expect(row?.paused).toBe(false);
|
||||
expect(deps.claudeFetchCount()).toBe(1);
|
||||
});
|
||||
|
||||
it('profile without .credentials.json -> parked row (needsReauth:true, no live fetch)', async () => {
|
||||
const clock = { now: 1_000_000 };
|
||||
const deps = makeMultiProfileDeps({
|
||||
clock,
|
||||
claudeProfiles: ['ck'],
|
||||
codexProfiles: [],
|
||||
claudeDefault: 'ck',
|
||||
credsForProfile: () => null, // no file on disk
|
||||
claudeFetch: async () => successQuota(),
|
||||
});
|
||||
|
||||
const rows = await getNativeAccountRows(deps);
|
||||
expect(rows.length).toBe(1);
|
||||
const row = rows[0];
|
||||
expect(row?.profile).toBe('ck');
|
||||
expect(row?.needsReauth).toBe(true);
|
||||
expect(row?.quota_percentage).toBeNull();
|
||||
// No live network call when creds are absent
|
||||
expect(deps.claudeFetchCount()).toBe(0);
|
||||
});
|
||||
|
||||
it('absent creds row has quotaStatus unsupported (honest "needs auth" state)', async () => {
|
||||
const clock = { now: 1_000_000 };
|
||||
const deps = makeMultiProfileDeps({
|
||||
clock,
|
||||
claudeProfiles: ['ck'],
|
||||
codexProfiles: [],
|
||||
claudeDefault: 'ck',
|
||||
credsForProfile: () => null,
|
||||
});
|
||||
|
||||
const rows = await getNativeAccountRows(deps);
|
||||
const row = rows[0];
|
||||
expect(row?.quotaStatus).toBe('unsupported');
|
||||
expect(row?.is_subscription).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('multi-profile: per-profile circuit breaker isolation', () => {
|
||||
it("one profile's 429 does not open another profile's breaker", async () => {
|
||||
const MAX_COOLDOWN_JUMP_MP = 61_000;
|
||||
const clock = { now: 1_000_000 };
|
||||
let workFails = true;
|
||||
|
||||
const deps = makeMultiProfileDeps({
|
||||
clock,
|
||||
claudeProfiles: ['work', 'ck'],
|
||||
codexProfiles: [],
|
||||
claudeDefault: 'work',
|
||||
credsForProfile: () => maxCreds(),
|
||||
claudeFetch: async (_token, accountId) => {
|
||||
// 'work' (ccs:work) always 429s; 'ck' always succeeds
|
||||
if (accountId?.includes('work') && workFails) {
|
||||
return {
|
||||
success: false,
|
||||
windows: [],
|
||||
coreUsage: { fiveHour: null, weekly: null },
|
||||
lastUpdated: Date.now(),
|
||||
accountId: accountId ?? 'ccs:work',
|
||||
httpStatus: 429,
|
||||
retryable: true,
|
||||
error: 'rate limited',
|
||||
} as ClaudeQuotaResult;
|
||||
}
|
||||
return successQuota();
|
||||
},
|
||||
});
|
||||
|
||||
// Trip the work breaker with 3 consecutive 429s.
|
||||
for (let i = 0; i < 3; i++) {
|
||||
resetNativeQuotaState();
|
||||
clock.now += i === 0 ? 0 : MAX_COOLDOWN_JUMP_MP;
|
||||
// Re-inject the multi-profile deps after reset so the state maps are fresh.
|
||||
await getNativeAccountRows({
|
||||
...deps,
|
||||
listClaudeProfiles: () => ['work'],
|
||||
listCodexProfiles: () => [],
|
||||
defaultClaudeProfile: () => 'work',
|
||||
});
|
||||
}
|
||||
|
||||
// After the three 429s on 'work', check that 'ck' still succeeds.
|
||||
// We reset state to have a clean run where 'ck' has no prior breaker history.
|
||||
resetNativeQuotaState();
|
||||
clock.now += MAX_COOLDOWN_JUMP_MP;
|
||||
workFails = false;
|
||||
|
||||
const rows = await getNativeAccountRows(deps);
|
||||
const ckRow = rows.find((r) => r.profile === 'ck');
|
||||
const workRow = rows.find((r) => r.profile === 'work');
|
||||
|
||||
// 'ck' should succeed — its breaker was never tripped.
|
||||
expect(ckRow?.quotaStatus).toBe('ok');
|
||||
// 'work' is also fine after reset (no breaker state).
|
||||
expect(workRow?.quotaStatus).toBe('ok');
|
||||
});
|
||||
|
||||
it("per-profile breaker: one profile's 429s only block that profile", async () => {
|
||||
const clock = { now: 1_000_000 };
|
||||
let workCall429Count = 0;
|
||||
|
||||
// 'work' returns 429 each call; 'ck' returns success.
|
||||
const deps = makeMultiProfileDeps({
|
||||
clock,
|
||||
claudeProfiles: ['work', 'ck'],
|
||||
codexProfiles: [],
|
||||
claudeDefault: 'work',
|
||||
credsForProfile: () => maxCreds(),
|
||||
claudeFetch: async (_token, accountId) => {
|
||||
if (accountId?.includes('work')) {
|
||||
workCall429Count += 1;
|
||||
return {
|
||||
success: false,
|
||||
windows: [],
|
||||
coreUsage: { fiveHour: null, weekly: null },
|
||||
lastUpdated: clock.now,
|
||||
accountId: accountId ?? '',
|
||||
httpStatus: 429,
|
||||
retryable: true,
|
||||
error: 'rate limited',
|
||||
} as ClaudeQuotaResult;
|
||||
}
|
||||
return successQuota();
|
||||
},
|
||||
});
|
||||
|
||||
// First call: 'work' gets a 429, 'ck' succeeds.
|
||||
const rows1 = await getNativeAccountRows(deps);
|
||||
const ck1 = rows1.find((r) => r.profile === 'ck');
|
||||
expect(ck1?.quotaStatus).toBe('ok');
|
||||
expect(workCall429Count).toBeGreaterThanOrEqual(1);
|
||||
|
||||
// Skip past cooldown for 'work' only; 'ck' is within TTL.
|
||||
clock.now += 62_000;
|
||||
|
||||
// Second call past 'work' cooldown: work tries again (429 again); ck cached.
|
||||
const rows2 = await getNativeAccountRows(deps);
|
||||
const ck2 = rows2.find((r) => r.profile === 'ck');
|
||||
// 'ck' still has a good cached row.
|
||||
expect(ck2?.quotaStatus).toBe('ok');
|
||||
});
|
||||
});
|
||||
|
||||
describe('multi-profile: displayName uses profile name', () => {
|
||||
it('displayName is the profile name, not "Claude Code" or "Codex"', async () => {
|
||||
const clock = { now: 1_000_000 };
|
||||
const deps = makeMultiProfileDeps({
|
||||
clock,
|
||||
claudeProfiles: ['my-work'],
|
||||
codexProfiles: ['my-codex'],
|
||||
claudeDefault: 'my-work',
|
||||
codexDefault: 'my-codex',
|
||||
credsForProfile: () => maxCreds(),
|
||||
codexNativeAuth: (p) => ({ accessToken: `tok-${p}`, accountId: `id-${p}` }),
|
||||
});
|
||||
|
||||
const rows = await getNativeAccountRows(deps);
|
||||
const c = rows.find((r) => r.surface === 'ccs');
|
||||
const x = rows.find((r) => r.surface === 'ccsx');
|
||||
expect(c?.displayName).toBe('my-work');
|
||||
expect(x?.displayName).toBe('my-codex');
|
||||
});
|
||||
});
|
||||
|
||||
describe('multi-profile: getCachedNativeAccountRows reflects per-profile maps', () => {
|
||||
it('returns cached rows from all profiles after a collect', async () => {
|
||||
const clock = { now: 1_000_000 };
|
||||
const deps = makeMultiProfileDeps({
|
||||
clock,
|
||||
claudeProfiles: ['work', 'ck'],
|
||||
codexProfiles: ['personal'],
|
||||
claudeDefault: 'work',
|
||||
codexDefault: 'personal',
|
||||
credsForProfile: () => maxCreds(),
|
||||
codexNativeAuth: (p) => ({ accessToken: `tok-${p}`, accountId: `id-${p}` }),
|
||||
});
|
||||
|
||||
await getNativeAccountRows(deps);
|
||||
|
||||
const cached = getCachedNativeAccountRows();
|
||||
expect(cached.every((r) => r.cached === true)).toBe(true);
|
||||
// Should have rows for work, ck, and personal (parked 'ck' has no cached row
|
||||
// yet because it had creds in this test so it did fetch)
|
||||
const profiles = cached.map((r) => r.profile);
|
||||
expect(profiles).toContain('work');
|
||||
expect(profiles).toContain('personal');
|
||||
|
||||
resetNativeQuotaState();
|
||||
expect(getCachedNativeAccountRows()).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('review focus areas: reauth caching + codex local fallback', () => {
|
||||
it('Claude reauth (401) profile is dimmed, cached, and not re-polled within cooldown', async () => {
|
||||
resetNativeQuotaState();
|
||||
const clock = { now: 5_000_000 };
|
||||
const deps = makeMultiProfileDeps({
|
||||
clock,
|
||||
claudeProfiles: ['work'],
|
||||
codexProfiles: [],
|
||||
claudeDefault: 'work',
|
||||
credsForProfile: () => maxCreds(),
|
||||
claudeFetch: async () => reauthQuota(),
|
||||
});
|
||||
|
||||
const first = await getNativeAccountRows(deps);
|
||||
const r1 = first.find((r) => r.profile === 'work');
|
||||
expect(r1?.needsReauth).toBe(true);
|
||||
expect(r1?.paused).toBe(true); // dimmed
|
||||
expect(deps.claudeFetchCount()).toBe(1);
|
||||
|
||||
// A forced refresh within the cooldown serves the cached reauth row and does
|
||||
// NOT re-hit the endpoint (no repeated 401 on the same account).
|
||||
const second = await getNativeAccountRows(deps, { force: true });
|
||||
const r2 = second.find((r) => r.profile === 'work');
|
||||
expect(r2?.needsReauth).toBe(true);
|
||||
expect(r2?.cached).toBe(true);
|
||||
expect(deps.claudeFetchCount()).toBe(1);
|
||||
});
|
||||
|
||||
it('Codex reauth (401) profile is dimmed, cached, and not re-polled within cooldown', async () => {
|
||||
resetNativeQuotaState();
|
||||
const clock = { now: 5_000_000 };
|
||||
const deps = makeMultiProfileDeps({
|
||||
clock,
|
||||
claudeProfiles: [],
|
||||
codexProfiles: ['ck'],
|
||||
codexDefault: 'default',
|
||||
codexNativeAuth: (p) => ({ accessToken: `t-${p}`, accountId: `id-${p}` }),
|
||||
codexNetworkFetch: async () => ({ success: false, needsReauth: true }) as CodexQuotaResult,
|
||||
});
|
||||
|
||||
const first = await getNativeAccountRows(deps);
|
||||
const r1 = first.find((r) => r.profile === 'ck');
|
||||
expect(r1?.needsReauth).toBe(true);
|
||||
expect(r1?.paused).toBe(true);
|
||||
expect(deps.codexNetworkCount()).toBe(1);
|
||||
|
||||
const second = await getNativeAccountRows(deps, { force: true });
|
||||
expect(second.find((r) => r.profile === 'ck')?.cached).toBe(true);
|
||||
expect(deps.codexNetworkCount()).toBe(1);
|
||||
});
|
||||
|
||||
it('Codex named profile without on-disk auth is parked, never filled from global local data', async () => {
|
||||
resetNativeQuotaState();
|
||||
const clock = { now: 5_000_000 };
|
||||
let localCalls = 0;
|
||||
const deps = makeMultiProfileDeps({
|
||||
clock,
|
||||
claudeProfiles: [],
|
||||
codexProfiles: ['ck'],
|
||||
codexDefault: 'default',
|
||||
codexNativeAuth: () => null, // no auth.json for the named profile
|
||||
codexLocalFallback: async () => {
|
||||
localCalls += 1;
|
||||
return codexLocalQuota();
|
||||
},
|
||||
});
|
||||
|
||||
const rows = await getNativeAccountRows(deps);
|
||||
const ck = rows.find((r) => r.profile === 'ck');
|
||||
expect(ck).toBeDefined();
|
||||
expect(ck?.paused).toBe(true); // parked, dimmed
|
||||
expect(ck?.needsReauth).toBe(true);
|
||||
expect(ck?.quota_percentage).toBeNull();
|
||||
// The global ~/.codex session data is never attributed to a named profile.
|
||||
expect(localCalls).toBe(0);
|
||||
});
|
||||
|
||||
it('Codex default profile without auth uses the global local session fallback', async () => {
|
||||
resetNativeQuotaState();
|
||||
const clock = { now: 5_000_000 };
|
||||
let localCalls = 0;
|
||||
const deps = makeMultiProfileDeps({
|
||||
clock,
|
||||
claudeProfiles: [],
|
||||
codexProfiles: ['default'],
|
||||
codexDefault: 'default',
|
||||
codexNativeAuth: () => null,
|
||||
codexLocalFallback: async () => {
|
||||
localCalls += 1;
|
||||
return codexLocalQuota();
|
||||
},
|
||||
});
|
||||
|
||||
const rows = await getNativeAccountRows(deps);
|
||||
const def = rows.find((r) => r.profile === 'default');
|
||||
expect(def).toBeDefined();
|
||||
// The bare default legitimately reflects the global ~/.codex local data.
|
||||
expect(localCalls).toBe(1);
|
||||
expect(def?.quotaStatus).not.toBe('unsupported');
|
||||
});
|
||||
|
||||
it('cache-fallback rows keep is_default for the default profile', async () => {
|
||||
resetNativeQuotaState();
|
||||
const clock = { now: 6_000_000 };
|
||||
const deps = makeMultiProfileDeps({
|
||||
clock,
|
||||
claudeProfiles: ['work', 'ck'],
|
||||
codexProfiles: ['default', 'ck'],
|
||||
claudeDefault: 'work',
|
||||
codexDefault: 'default',
|
||||
credsForProfile: () => maxCreds(),
|
||||
claudeFetch: async () => successQuota(),
|
||||
codexNativeAuth: (p) => ({ accessToken: `t-${p}`, accountId: `id-${p}` }),
|
||||
codexNetworkFetch: async () => codexSuccessQuota(),
|
||||
});
|
||||
|
||||
await getNativeAccountRows(deps); // populate the per-profile caches
|
||||
|
||||
// The cache-fallback path must preserve is_default so the UI still orders and
|
||||
// tags the default account when /summary serves from cache.
|
||||
const cached = getCachedNativeAccountRows();
|
||||
expect(cached.find((r) => r.surface === 'ccs' && r.profile === 'work')?.is_default).toBe(true);
|
||||
expect(cached.find((r) => r.surface === 'ccsx' && r.profile === 'default')?.is_default).toBe(
|
||||
true
|
||||
);
|
||||
expect(cached.find((r) => r.surface === 'ccs' && r.profile === 'ck')?.is_default).toBe(false);
|
||||
expect(cached.find((r) => r.surface === 'ccsx' && r.profile === 'ck')?.is_default).toBe(false);
|
||||
});
|
||||
|
||||
it('parked (no-creds) rows use a short TTL so a fresh login is detected quickly', async () => {
|
||||
resetNativeQuotaState();
|
||||
const clock = { now: 7_000_000 };
|
||||
let hasCreds = false;
|
||||
const deps = makeMultiProfileDeps({
|
||||
clock,
|
||||
claudeProfiles: ['work'],
|
||||
codexProfiles: [],
|
||||
claudeDefault: 'work',
|
||||
credsForProfile: () => (hasCreds ? maxCreds() : null),
|
||||
claudeFetch: async () => successQuota(),
|
||||
});
|
||||
|
||||
// First poll: no creds -> parked, no network.
|
||||
const first = await getNativeAccountRows(deps);
|
||||
expect(first.find((r) => r.profile === 'work')?.paused).toBe(true);
|
||||
expect(deps.claudeFetchCount()).toBe(0);
|
||||
|
||||
// User logs in; advance past the short parked TTL (30s) but far within the
|
||||
// full quota TTL (10min). The parked row must NOT be served stale.
|
||||
hasCreds = true;
|
||||
clock.now += 31_000;
|
||||
const second = await getNativeAccountRows(deps);
|
||||
const work = second.find((r) => r.profile === 'work');
|
||||
expect(work?.paused).toBe(false); // now live, not dimmed
|
||||
expect(work?.quotaStatus).toBe('ok');
|
||||
expect(deps.claudeFetchCount()).toBe(1); // re-checked -> fetched
|
||||
});
|
||||
|
||||
it('Codex named profile with valid auth but sparse payload stays active, not parked', async () => {
|
||||
resetNativeQuotaState();
|
||||
const clock = { now: 7_000_000 };
|
||||
const deps = makeMultiProfileDeps({
|
||||
clock,
|
||||
claudeProfiles: [],
|
||||
codexProfiles: ['ck'],
|
||||
codexDefault: 'default',
|
||||
codexNativeAuth: (p) => ({ accessToken: `t-${p}`, accountId: `id-${p}` }),
|
||||
// Successful response but NO core windows (sparse / changed payload).
|
||||
codexNetworkFetch: async () => ({ success: true }) as CodexQuotaResult,
|
||||
});
|
||||
|
||||
const rows = await getNativeAccountRows(deps);
|
||||
const ck = rows.find((r) => r.profile === 'ck');
|
||||
expect(ck).toBeDefined();
|
||||
expect(ck?.paused).toBe(false); // active subscription, not parked
|
||||
expect(ck?.needsReauth).toBe(false);
|
||||
expect(ck?.quotaStatus).toBe('ok');
|
||||
expect(ck?.quota_percentage).toBeNull(); // no windows, but still active
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user