@@ -3776,6 +3776,197 @@ describe("usage reliability: non-destructive merge", () => {
37763776 service . dispose ( ) ;
37773777 } ) ;
37783778
3779+ it ( "keeps a valid Claude status when a mobile refresh cannot read interactive credentials" , async ( ) => {
3780+ const logger = createLogger ( ) ;
3781+ const weeklyMs = 3 * 24 * 60 * 60 * 1000 ;
3782+ const claudeWindow = {
3783+ provider : "claude" as const ,
3784+ windowType : "weekly" as const ,
3785+ percentUsed : 37 ,
3786+ resetsAt : futureIso ( weeklyMs ) ,
3787+ resetsInMs : weeklyMs ,
3788+ } ;
3789+ const claudeExtraUsage = {
3790+ provider : "claude" as const ,
3791+ isEnabled : true ,
3792+ usedCreditsUsd : 9 ,
3793+ monthlyLimitUsd : 100 ,
3794+ utilization : 9 ,
3795+ currency : "usd" ,
3796+ } ;
3797+ const pollClaudeUsage = vi
3798+ . fn ( )
3799+ . mockResolvedValueOnce ( {
3800+ windows : [ claudeWindow ] ,
3801+ errors : [ ] ,
3802+ source : "oauth" as const ,
3803+ extraUsage : claudeExtraUsage ,
3804+ } )
3805+ . mockResolvedValueOnce ( {
3806+ disposition : "preserve_previous" as const ,
3807+ windows : [ ] ,
3808+ errors : [ ] ,
3809+ source : "oauth" as const ,
3810+ } ) ;
3811+ const service = createUsageTrackingService ( {
3812+ logger,
3813+ dependencies : {
3814+ pollClaudeUsage,
3815+ pollCodexUsage : vi . fn ( async ( ) => ( { windows : [ ] , errors : [ ] } ) ) ,
3816+ } ,
3817+ } ) ;
3818+
3819+ const first = await service . poll ( { reason : "user" } ) ;
3820+ const refreshed = await service . poll ( { reason : "remote" } ) ;
3821+
3822+ expect ( first . providerStatus ?. claude ?. state ) . toBe ( "ok" ) ;
3823+ expect ( refreshed . providerStatus ?. claude ) . toEqual ( first . providerStatus ?. claude ) ;
3824+ expect ( refreshed . windows . filter ( ( window ) => window . provider === "claude" ) ) . toHaveLength ( 1 ) ;
3825+ expect ( refreshed . windows . find ( ( window ) => window . provider === "claude" ) ) . toMatchObject ( claudeWindow ) ;
3826+ expect ( refreshed . extraUsage ) . toEqual ( [ claudeExtraUsage ] ) ;
3827+ expect ( refreshed . errors ) . toEqual ( [ ] ) ;
3828+
3829+ service . dispose ( ) ;
3830+ } ) ;
3831+
3832+ it ( "downgrades a preserved provider after its final carried window expires" , async ( ) => {
3833+ vi . useFakeTimers ( ) ;
3834+ vi . setSystemTime ( new Date ( "2026-07-17T12:00:00.000Z" ) ) ;
3835+ const pollClaudeUsage = vi
3836+ . fn ( )
3837+ . mockResolvedValueOnce ( {
3838+ windows : [ {
3839+ provider : "claude" as const ,
3840+ windowType : "weekly" as const ,
3841+ percentUsed : 37 ,
3842+ resetsAt : "2026-07-17T12:01:00.000Z" ,
3843+ resetsInMs : 60_000 ,
3844+ } ] ,
3845+ errors : [ ] ,
3846+ source : "oauth" as const ,
3847+ } )
3848+ . mockResolvedValueOnce ( {
3849+ disposition : "preserve_previous" as const ,
3850+ windows : [ ] ,
3851+ errors : [ ] ,
3852+ source : "oauth" as const ,
3853+ } ) ;
3854+ const service = createUsageTrackingService ( {
3855+ logger : createLogger ( ) ,
3856+ dependencies : {
3857+ pollClaudeUsage,
3858+ pollCodexUsage : vi . fn ( async ( ) => ( { windows : [ ] , errors : [ ] } ) ) ,
3859+ } ,
3860+ } ) ;
3861+
3862+ try {
3863+ const first = await service . poll ( { reason : "user" } ) ;
3864+ expect ( first . providerStatus ?. claude ?. state ) . toBe ( "ok" ) ;
3865+
3866+ vi . advanceTimersByTime ( 60_000 ) ;
3867+ const expired = await service . poll ( { reason : "remote" } ) ;
3868+
3869+ expect ( expired . windows . filter ( ( window ) => window . provider === "claude" ) ) . toEqual ( [ ] ) ;
3870+ expect ( expired . providerStatus ?. claude ) . toMatchObject ( {
3871+ state : "stale" ,
3872+ lastSuccessAt : first . lastPolledAt ,
3873+ } ) ;
3874+ expect ( expired . providerStatus ?. claude ?. message ) . toMatch ( / e x p i r e d / i) ;
3875+ } finally {
3876+ service . dispose ( ) ;
3877+ vi . useRealTimers ( ) ;
3878+ }
3879+ } ) ;
3880+
3881+ it ( "clears a sticky Claude auth error when a remote refresh cannot verify credentials" , async ( ) => {
3882+ const logger = createLogger ( ) ;
3883+ const weeklyMs = 3 * 24 * 60 * 60 * 1000 ;
3884+ const claudeWindow = {
3885+ provider : "claude" as const ,
3886+ windowType : "weekly" as const ,
3887+ percentUsed : 37 ,
3888+ resetsAt : futureIso ( weeklyMs ) ,
3889+ resetsInMs : weeklyMs ,
3890+ } ;
3891+ const pollClaudeUsage = vi
3892+ . fn ( )
3893+ . mockResolvedValueOnce ( { windows : [ claudeWindow ] , errors : [ ] , source : "oauth" as const } )
3894+ . mockResolvedValueOnce ( {
3895+ windows : [ ] ,
3896+ errors : [ "claude: no non-interactive credentials found" ] ,
3897+ errorKind : "auth" as const ,
3898+ source : "oauth" as const ,
3899+ } )
3900+ . mockResolvedValueOnce ( {
3901+ disposition : "preserve_previous" as const ,
3902+ windows : [ ] ,
3903+ errors : [ ] ,
3904+ source : "oauth" as const ,
3905+ } ) ;
3906+ const service = createUsageTrackingService ( {
3907+ logger,
3908+ dependencies : {
3909+ pollClaudeUsage,
3910+ pollCodexUsage : vi . fn ( async ( ) => ( { windows : [ ] , errors : [ ] } ) ) ,
3911+ } ,
3912+ } ) ;
3913+
3914+ await service . poll ( { reason : "user" } ) ;
3915+ const staleError = await service . poll ( { reason : "remote" } ) ;
3916+ const refreshed = await service . poll ( { reason : "remote" } ) ;
3917+
3918+ expect ( staleError . providerStatus ?. claude ?. state ) . toBe ( "unauthed" ) ;
3919+ expect ( refreshed . providerStatus ?. claude ) . toMatchObject ( {
3920+ state : "stale" ,
3921+ lastSuccessAt : expect . any ( String ) ,
3922+ } ) ;
3923+ expect ( refreshed . providerStatus ?. claude ?. message ) . toBeUndefined ( ) ;
3924+ expect ( refreshed . windows . filter ( ( window ) => window . provider === "claude" ) ) . toHaveLength ( 1 ) ;
3925+ expect ( refreshed . errors ) . toEqual ( [ ] ) ;
3926+
3927+ service . dispose ( ) ;
3928+ } ) ;
3929+
3930+ it ( "preserves a genuine Claude auth error while automatic polling is backed off" , async ( ) => {
3931+ vi . useFakeTimers ( ) ;
3932+ vi . setSystemTime ( new Date ( "2026-07-17T12:00:00.000Z" ) ) ;
3933+ const logger = createLogger ( ) ;
3934+ const pollClaudeUsage = vi
3935+ . fn ( )
3936+ . mockResolvedValueOnce ( {
3937+ windows : [ ] ,
3938+ errors : [ "claude: API returned 401" ] ,
3939+ errorKind : "auth" as const ,
3940+ source : "oauth" as const ,
3941+ } )
3942+ . mockResolvedValueOnce ( {
3943+ disposition : "preserve_previous" as const ,
3944+ windows : [ ] ,
3945+ errors : [ ] ,
3946+ source : "oauth" as const ,
3947+ } ) ;
3948+ const service = createUsageTrackingService ( {
3949+ logger,
3950+ dependencies : {
3951+ pollClaudeUsage,
3952+ pollCodexUsage : vi . fn ( async ( ) => ( { windows : [ ] , errors : [ ] } ) ) ,
3953+ } ,
3954+ } ) ;
3955+
3956+ const failed = await service . poll ( { reason : "user" } ) ;
3957+ const backedOff = await service . poll ( { reason : "automatic" } ) ;
3958+ vi . advanceTimersByTime ( 61_000 ) ;
3959+ const nonInteractive = await service . poll ( { reason : "automatic" } ) ;
3960+
3961+ expect ( failed . providerStatus ?. claude ?. state ) . toBe ( "unauthed" ) ;
3962+ expect ( backedOff . providerStatus ?. claude ) . toEqual ( failed . providerStatus ?. claude ) ;
3963+ expect ( nonInteractive . providerStatus ?. claude ) . toEqual ( failed . providerStatus ?. claude ) ;
3964+ expect ( pollClaudeUsage ) . toHaveBeenCalledTimes ( 2 ) ;
3965+
3966+ service . dispose ( ) ;
3967+ vi . useRealTimers ( ) ;
3968+ } ) ;
3969+
37793970 it ( "attaches per-window pacing for both the 5-hour and weekly windows" , async ( ) => {
37803971 const logger = createLogger ( ) ;
37813972 const fiveHourMs = 2 * 60 * 60 * 1000 ; // 60% of the 5h window elapsed
0 commit comments