chore: revert agent subscription usage in settings and chat tooltip (#1940)
* chore: revert agent subscription usage in settings and chat tooltip
Reverts #1697 (55c8b461b). The subscription usage plugin now covers this,
so the built-in Settings > Agents section and the context-doughnut tooltip
rows are no longer needed.
Conflicts resolved to preserve later work on the same files:
- token-usage-display.tsx/.test.tsx: kept the pinnable tooltip (#1869),
the rebuilt context-window layout (#1741), and the ACP/API source row
(#1752); removed only SessionUsageRows and its imports.
- settings/agents/page.tsx: #1741 moved <AgentUsageSection /> above the
pre-existing <Separator />, so the revert missed the JSX; removed it
and kept the separator.
- agent-usage-section.tsx/.test.tsx: deleted (later changes were cosmetic
tweaks to the section itself).
Office-side subscription surfaces (dashboard quota card, agent detail
QuotaCard, internal/agent/usage clients) predate #1697 and are untouched.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(usage): keep #1697 client fixes when reverting its UI surface
#1697 bundled two things: the Settings > Agents section plus doughnut
tooltip rows, and a set of genuine bug fixes to the underlying provider
usage clients. Reverting it wholesale threw away the second group, which
is still live code behind the office dashboard quota card and the agent
detail QuotaCard.
Restores the client layer to its post-#1697 state (billing.go, cache.go,
client_claude.go, client_codex.go, fileutil.go, types.go and the two
client tests), fixing the defects CodeRabbit flagged on #1940:
- billing type is recomputed per call again instead of being frozen for
the process lifetime by sync.OnceValue, so login/logout is observed
without a backend restart
- usage cache regains fetch coalescing and short negative caching, so a
provider outage no longer means one outbound call per request
- Claude usage parses float utilization and the richer limits[] array
instead of failing to decode
- Claude token refresh patches only claudeAiOauth in a decoded map and
writes atomically via temp+rename, preserving unknown keys in the
credentials file the real CLI shares
- Codex reads tokens.access_token rather than a nonexistent flat token
field, and its doc comment matches the ~/.codex/auth.json path
The removed UI/API surface stays reverted: GET /api/v1/agents/usage, the
host usage lister, the settings section, and the tooltip rows.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Kandev Agent <agent@kandev.dev>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -56,7 +56,6 @@ type Controller struct {
|
||||
mcpService *mcpconfig.Service
|
||||
modelCache *modelfetcher.Cache
|
||||
hostUtility *hostutility.Manager
|
||||
hostUsage HostUsageLister
|
||||
jobStore *JobStore
|
||||
hub JobBroadcaster
|
||||
logger *logger.Logger
|
||||
|
||||
@@ -1,43 +0,0 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/kandev/kandev/internal/agent/settings/dto"
|
||||
agentusage "github.com/kandev/kandev/internal/agent/usage"
|
||||
)
|
||||
|
||||
// HostUsageLister lists subscription utilization for host-installed agent CLIs.
|
||||
// Implemented by usage.HostService. fresh bypasses the 5-minute cache (still
|
||||
// bounded by a short server-side clamp).
|
||||
type HostUsageLister interface {
|
||||
List(ctx context.Context, fresh bool) []agentusage.HostAgentUsage
|
||||
}
|
||||
|
||||
// SetHostUsageLister wires in the host subscription-usage service. Optional —
|
||||
// when unset SubscriptionUsage returns an empty listing.
|
||||
func (c *Controller) SetHostUsageLister(l HostUsageLister) {
|
||||
c.hostUsage = l
|
||||
}
|
||||
|
||||
// SubscriptionUsage returns utilization for host agents authenticated with
|
||||
// subscription (OAuth) credentials, enriched with registry display names.
|
||||
func (c *Controller) SubscriptionUsage(ctx context.Context, fresh bool) *dto.AgentSubscriptionUsageResponse {
|
||||
resp := &dto.AgentSubscriptionUsageResponse{Agents: []dto.AgentSubscriptionUsage{}}
|
||||
if c.hostUsage == nil {
|
||||
return resp
|
||||
}
|
||||
for _, entry := range c.hostUsage.List(ctx, fresh) {
|
||||
displayName := entry.AgentID
|
||||
if ag, ok := c.agentRegistry.Get(entry.AgentID); ok {
|
||||
displayName = ag.DisplayName()
|
||||
}
|
||||
resp.Agents = append(resp.Agents, dto.AgentSubscriptionUsage{
|
||||
AgentID: entry.AgentID,
|
||||
DisplayName: displayName,
|
||||
Usage: entry.Usage,
|
||||
Error: entry.Error,
|
||||
})
|
||||
}
|
||||
return resp
|
||||
}
|
||||
@@ -1,91 +0,0 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/kandev/kandev/internal/agent/agents"
|
||||
agentusage "github.com/kandev/kandev/internal/agent/usage"
|
||||
)
|
||||
|
||||
type fakeHostUsageLister struct {
|
||||
entries []agentusage.HostAgentUsage
|
||||
lastFresh bool
|
||||
calls int
|
||||
}
|
||||
|
||||
func (f *fakeHostUsageLister) List(_ context.Context, fresh bool) []agentusage.HostAgentUsage {
|
||||
f.calls++
|
||||
f.lastFresh = fresh
|
||||
return f.entries
|
||||
}
|
||||
|
||||
func TestControllerSubscriptionUsage_NoLister(t *testing.T) {
|
||||
ctrl := newTestController(map[string]agents.Agent{})
|
||||
|
||||
resp := ctrl.SubscriptionUsage(context.Background(), false)
|
||||
|
||||
if resp == nil || resp.Agents == nil {
|
||||
t.Fatalf("expected non-nil response with empty agents, got %+v", resp)
|
||||
}
|
||||
if len(resp.Agents) != 0 {
|
||||
t.Fatalf("expected empty listing without a lister, got %+v", resp.Agents)
|
||||
}
|
||||
}
|
||||
|
||||
func TestControllerSubscriptionUsage_MapsEntries(t *testing.T) {
|
||||
ctrl := newTestController(map[string]agents.Agent{
|
||||
"claude-acp": &testAgent{id: "claude-acp", name: "claude-acp", displayName: "Claude", enabled: true},
|
||||
})
|
||||
now := time.Now()
|
||||
usage := &agentusage.ProviderUsage{
|
||||
Provider: "anthropic",
|
||||
Plan: "max",
|
||||
Windows: []agentusage.UtilizationWindow{{Label: "5-hour", UtilizationPct: 42, ResetAt: now}},
|
||||
FetchedAt: now,
|
||||
}
|
||||
lister := &fakeHostUsageLister{entries: []agentusage.HostAgentUsage{
|
||||
{AgentID: "claude-acp", Usage: usage},
|
||||
{AgentID: "unknown-acp", Error: "failed to fetch usage from provider"},
|
||||
}}
|
||||
ctrl.SetHostUsageLister(lister)
|
||||
|
||||
resp := ctrl.SubscriptionUsage(context.Background(), false)
|
||||
|
||||
if len(resp.Agents) != 2 {
|
||||
t.Fatalf("agents = %+v, want 2 entries", resp.Agents)
|
||||
}
|
||||
// Registry display-name lookup for known agents.
|
||||
if resp.Agents[0].AgentID != "claude-acp" || resp.Agents[0].DisplayName != "Claude" {
|
||||
t.Errorf("entry[0] = %+v", resp.Agents[0])
|
||||
}
|
||||
if resp.Agents[0].Usage != usage || resp.Agents[0].Error != "" {
|
||||
t.Errorf("entry[0] usage/error = %+v", resp.Agents[0])
|
||||
}
|
||||
// Unknown agents fall back to the agent ID and forward the error.
|
||||
if resp.Agents[1].DisplayName != "unknown-acp" || resp.Agents[1].Error == "" {
|
||||
t.Errorf("entry[1] = %+v", resp.Agents[1])
|
||||
}
|
||||
if resp.Agents[1].Usage != nil {
|
||||
t.Errorf("entry[1] usage = %+v, want nil", resp.Agents[1].Usage)
|
||||
}
|
||||
}
|
||||
|
||||
func TestControllerSubscriptionUsage_PropagatesFresh(t *testing.T) {
|
||||
ctrl := newTestController(map[string]agents.Agent{})
|
||||
lister := &fakeHostUsageLister{}
|
||||
ctrl.SetHostUsageLister(lister)
|
||||
|
||||
_ = ctrl.SubscriptionUsage(context.Background(), true)
|
||||
if !lister.lastFresh {
|
||||
t.Error("fresh=true was not propagated to the lister")
|
||||
}
|
||||
_ = ctrl.SubscriptionUsage(context.Background(), false)
|
||||
if lister.lastFresh {
|
||||
t.Error("fresh=false was not propagated to the lister")
|
||||
}
|
||||
if lister.calls != 2 {
|
||||
t.Errorf("lister calls = %d, want 2", lister.calls)
|
||||
}
|
||||
}
|
||||
@@ -4,23 +4,8 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/kandev/kandev/internal/agent/mcpconfig"
|
||||
agentusage "github.com/kandev/kandev/internal/agent/usage"
|
||||
)
|
||||
|
||||
// AgentSubscriptionUsage is one subscription-billed host agent's utilization
|
||||
// entry returned by GET /api/v1/agents/usage.
|
||||
type AgentSubscriptionUsage struct {
|
||||
AgentID string `json:"agent_id"`
|
||||
DisplayName string `json:"display_name"`
|
||||
Usage *agentusage.ProviderUsage `json:"usage,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
// AgentSubscriptionUsageResponse is the GET /api/v1/agents/usage payload.
|
||||
type AgentSubscriptionUsageResponse struct {
|
||||
Agents []AgentSubscriptionUsage `json:"agents"`
|
||||
}
|
||||
|
||||
type AgentProfileDTO struct {
|
||||
ID string `json:"id"`
|
||||
AgentID string `json:"agent_id"`
|
||||
|
||||
@@ -19,8 +19,6 @@ import (
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
const queryValueTrue = "true"
|
||||
|
||||
var availableAgentsBroadcastTimeout = 10 * time.Second
|
||||
|
||||
type Handlers struct {
|
||||
@@ -55,7 +53,6 @@ func (h *Handlers) registerHTTP(router *gin.Engine) {
|
||||
api := router.Group("/api/v1")
|
||||
api.GET("/agents/discovery", h.httpDiscoverAgents)
|
||||
api.GET("/agents/available", h.httpListAvailableAgents)
|
||||
api.GET("/agents/usage", h.httpAgentSubscriptionUsage)
|
||||
api.GET("/agents", h.httpListAgents)
|
||||
api.POST("/agents", h.interlock, h.httpCreateAgent)
|
||||
api.POST("/agents/tui", h.interlock, h.httpCreateCustomTUIAgent)
|
||||
@@ -87,11 +84,6 @@ func (h *Handlers) httpDiscoverAgents(c *gin.Context) {
|
||||
h.broadcastAvailableAgentsAsync()
|
||||
}
|
||||
|
||||
func (h *Handlers) httpAgentSubscriptionUsage(c *gin.Context) {
|
||||
fresh := c.Query("fresh") == queryValueTrue || c.Query("fresh") == "1"
|
||||
c.JSON(http.StatusOK, h.controller.SubscriptionUsage(c.Request.Context(), fresh))
|
||||
}
|
||||
|
||||
func (h *Handlers) httpListAvailableAgents(c *gin.Context) {
|
||||
resp, err := h.controller.ListAvailableAgents(c.Request.Context())
|
||||
if err != nil {
|
||||
@@ -485,7 +477,7 @@ func (h *Handlers) httpUpdateProfile(c *gin.Context) {
|
||||
}
|
||||
|
||||
func (h *Handlers) httpDeleteProfile(c *gin.Context) {
|
||||
force := c.Query("force") == queryValueTrue
|
||||
force := c.Query("force") == "true"
|
||||
profile, err := h.controller.DeleteProfile(c.Request.Context(), c.Param("id"), force)
|
||||
if err != nil {
|
||||
if err == controller.ErrAgentProfileNotFound {
|
||||
@@ -589,7 +581,7 @@ func (h *Handlers) httpGetAgentModels(c *gin.Context) {
|
||||
}
|
||||
|
||||
// Check for refresh query parameter
|
||||
refresh := c.Query("refresh") == queryValueTrue
|
||||
refresh := c.Query("refresh") == "true"
|
||||
|
||||
resp, err := h.controller.FetchDynamicModels(c.Request.Context(), agentName, refresh)
|
||||
if err != nil {
|
||||
|
||||
@@ -1,106 +0,0 @@
|
||||
package usage
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"go.uber.org/zap"
|
||||
|
||||
"github.com/kandev/kandev/internal/common/logger"
|
||||
)
|
||||
|
||||
// freshMaxAge clamps fresh (hover-triggered) fetches: a provider is queried
|
||||
// again at most every 15 s even when callers keep requesting fresh data.
|
||||
const freshMaxAge = 15 * time.Second
|
||||
|
||||
// hostUsageFetchError is the sanitized per-agent error surfaced through the
|
||||
// API. Raw provider errors can contain credential paths and response bodies,
|
||||
// so those are only logged server-side.
|
||||
const hostUsageFetchError = "failed to fetch usage from provider"
|
||||
|
||||
// HostAgentUsage is one host-installed subscription agent's usage entry.
|
||||
type HostAgentUsage struct {
|
||||
AgentID string `json:"agent_id"`
|
||||
Usage *ProviderUsage `json:"usage,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
// hostUsageClient is a ProviderUsageClient that can also report whether
|
||||
// subscription credentials are present on disk.
|
||||
type hostUsageClient interface {
|
||||
ProviderUsageClient
|
||||
HasSubscriptionCredentials() bool
|
||||
}
|
||||
|
||||
type hostEntry struct {
|
||||
agentID string
|
||||
cacheKey string
|
||||
client hostUsageClient
|
||||
}
|
||||
|
||||
// HostService lists subscription utilization for agent CLIs installed on the
|
||||
// kandev host (Claude Code, Codex). Agents without subscription credentials
|
||||
// are omitted from List results.
|
||||
type HostService struct {
|
||||
cache *UsageCache
|
||||
entries []hostEntry
|
||||
logger *logger.Logger
|
||||
}
|
||||
|
||||
// NewHostService builds a HostService reading credentials from the current
|
||||
// user's home directory. The service is empty when the home dir is unavailable.
|
||||
func NewHostService(log *logger.Logger) *HostService {
|
||||
s := &HostService{cache: NewUsageCache(), logger: log}
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return s
|
||||
}
|
||||
claudePath := filepath.Join(home, ".claude", ".credentials.json")
|
||||
codexPath := filepath.Join(home, ".codex", "auth.json")
|
||||
s.entries = []hostEntry{
|
||||
{
|
||||
agentID: "claude-acp",
|
||||
cacheKey: CacheKey("anthropic", claudePath),
|
||||
client: NewClaudeUsageClientWithPath(claudePath),
|
||||
},
|
||||
{
|
||||
agentID: "codex-acp",
|
||||
cacheKey: CacheKey("openai", codexPath),
|
||||
client: NewCodexUsageClientWithPath(codexPath),
|
||||
},
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// List returns usage for every host agent that has subscription credentials.
|
||||
// With fresh=false cached results up to 5 minutes old are served; fresh=true
|
||||
// re-queries the providers, bounded by freshMaxAge. Fetch failures are
|
||||
// reported per-agent via the Error field rather than failing the whole listing.
|
||||
func (s *HostService) List(ctx context.Context, fresh bool) []HostAgentUsage {
|
||||
maxAge := cacheTTL
|
||||
if fresh {
|
||||
maxAge = freshMaxAge
|
||||
}
|
||||
out := make([]HostAgentUsage, 0, len(s.entries))
|
||||
for _, e := range s.entries {
|
||||
if !e.client.HasSubscriptionCredentials() {
|
||||
continue
|
||||
}
|
||||
entry := HostAgentUsage{AgentID: e.agentID}
|
||||
u, err := s.cache.GetOrFetchWithin(ctx, e.cacheKey, maxAge, e.client.FetchUsage)
|
||||
switch {
|
||||
case err != nil:
|
||||
if s.logger != nil {
|
||||
s.logger.Warn("subscription usage fetch failed",
|
||||
zap.String("agent", e.agentID), zap.Error(err))
|
||||
}
|
||||
entry.Error = hostUsageFetchError
|
||||
default:
|
||||
entry.Usage = u
|
||||
}
|
||||
out = append(out, entry)
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -1,240 +0,0 @@
|
||||
package usage
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
type fakeHostClient struct {
|
||||
hasCreds bool
|
||||
usage *ProviderUsage
|
||||
err error
|
||||
calls int
|
||||
}
|
||||
|
||||
func (f *fakeHostClient) FetchUsage(_ context.Context) (*ProviderUsage, error) {
|
||||
f.calls++
|
||||
return f.usage, f.err
|
||||
}
|
||||
|
||||
func (f *fakeHostClient) HasSubscriptionCredentials() bool { return f.hasCreds }
|
||||
|
||||
func TestHostServiceList(t *testing.T) {
|
||||
now := time.Now()
|
||||
okUsage := &ProviderUsage{
|
||||
Provider: "anthropic",
|
||||
Plan: "max",
|
||||
Windows: []UtilizationWindow{{Label: "5-hour", UtilizationPct: 42, ResetAt: now}},
|
||||
FetchedAt: now,
|
||||
}
|
||||
noCreds := &fakeHostClient{hasCreds: false}
|
||||
ok := &fakeHostClient{hasCreds: true, usage: okUsage}
|
||||
failing := &fakeHostClient{hasCreds: true, err: errors.New("boom")}
|
||||
|
||||
svc := &HostService{
|
||||
cache: NewUsageCache(),
|
||||
entries: []hostEntry{
|
||||
{agentID: "claude-acp", cacheKey: "k1", client: noCreds},
|
||||
{agentID: "codex-acp", cacheKey: "k2", client: ok},
|
||||
{agentID: "other-acp", cacheKey: "k3", client: failing},
|
||||
},
|
||||
}
|
||||
|
||||
got := svc.List(context.Background(), false)
|
||||
if len(got) != 2 {
|
||||
t.Fatalf("List = %+v, want 2 entries", got)
|
||||
}
|
||||
if got[0].AgentID != "codex-acp" || got[0].Usage != okUsage || got[0].Error != "" {
|
||||
t.Errorf("entry[0] = %+v", got[0])
|
||||
}
|
||||
if got[1].AgentID != "other-acp" || got[1].Usage != nil || got[1].Error != hostUsageFetchError {
|
||||
t.Errorf("entry[1] = %+v", got[1])
|
||||
}
|
||||
if noCreds.calls != 0 {
|
||||
t.Errorf("client without creds was fetched %d times", noCreds.calls)
|
||||
}
|
||||
|
||||
// Second List hits the cache for the successful entry.
|
||||
_ = svc.List(context.Background(), false)
|
||||
if ok.calls != 1 {
|
||||
t.Errorf("expected cached fetch, got %d calls", ok.calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHostServiceList_FreshBypassesStaleCache(t *testing.T) {
|
||||
now := time.Now()
|
||||
okUsage := &ProviderUsage{Provider: "anthropic", FetchedAt: now}
|
||||
ok := &fakeHostClient{hasCreds: true, usage: okUsage}
|
||||
svc := &HostService{
|
||||
cache: NewUsageCache(),
|
||||
entries: []hostEntry{{agentID: "claude-acp", cacheKey: "k1", client: ok}},
|
||||
}
|
||||
|
||||
_ = svc.List(context.Background(), false)
|
||||
if ok.calls != 1 {
|
||||
t.Fatalf("initial fetch calls = %d", ok.calls)
|
||||
}
|
||||
|
||||
// Entry is younger than freshMaxAge: fresh serves the cached value.
|
||||
_ = svc.List(context.Background(), true)
|
||||
if ok.calls != 1 {
|
||||
t.Errorf("fresh within clamp should hit cache, calls = %d", ok.calls)
|
||||
}
|
||||
|
||||
// Age the entry past freshMaxAge but below the 5-minute TTL: a normal List
|
||||
// still serves the cache, a fresh List re-queries the provider.
|
||||
svc.cache.mu.Lock()
|
||||
svc.cache.entries["k1"].successAt = now.Add(-freshMaxAge - time.Second)
|
||||
svc.cache.mu.Unlock()
|
||||
|
||||
_ = svc.List(context.Background(), false)
|
||||
if ok.calls != 1 {
|
||||
t.Errorf("stale-but-within-TTL cached List should not refetch, calls = %d", ok.calls)
|
||||
}
|
||||
_ = svc.List(context.Background(), true)
|
||||
if ok.calls != 2 {
|
||||
t.Errorf("fresh List should refetch, calls = %d", ok.calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewHostServiceRegistersHostAgents(t *testing.T) {
|
||||
svc := NewHostService(nil)
|
||||
if len(svc.entries) != 2 {
|
||||
t.Fatalf("entries = %d, want 2", len(svc.entries))
|
||||
}
|
||||
if svc.entries[0].agentID != "claude-acp" || svc.entries[1].agentID != "codex-acp" {
|
||||
t.Errorf("agent IDs = %q, %q", svc.entries[0].agentID, svc.entries[1].agentID)
|
||||
}
|
||||
}
|
||||
|
||||
// runCoalescedFetch launches n concurrent GetOrFetchWithin callers whose first
|
||||
// fetch blocks until every goroutine has been started, maximizing contention
|
||||
// without sleep-based synchronization. It returns the per-caller results.
|
||||
func runCoalescedFetch(
|
||||
t *testing.T,
|
||||
cache *UsageCache,
|
||||
n int,
|
||||
result *ProviderUsage,
|
||||
fetchErr error,
|
||||
calls *atomic.Int32,
|
||||
) {
|
||||
t.Helper()
|
||||
release := make(chan struct{})
|
||||
fetch := func(_ context.Context) (*ProviderUsage, error) {
|
||||
calls.Add(1)
|
||||
<-release
|
||||
return result, fetchErr
|
||||
}
|
||||
|
||||
var started, wg sync.WaitGroup
|
||||
for range n {
|
||||
started.Add(1)
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
started.Done()
|
||||
_, err := cache.GetOrFetchWithin(context.Background(), "k", freshMaxAge, fetch)
|
||||
if fetchErr == nil && err != nil {
|
||||
t.Errorf("GetOrFetchWithin: %v", err)
|
||||
}
|
||||
if fetchErr != nil && err == nil {
|
||||
t.Error("expected error from coalesced failing fetch")
|
||||
}
|
||||
}()
|
||||
}
|
||||
started.Wait()
|
||||
close(release)
|
||||
wg.Wait()
|
||||
}
|
||||
|
||||
func TestUsageCacheCoalescesConcurrentFetches(t *testing.T) {
|
||||
cache := NewUsageCache()
|
||||
var calls atomic.Int32
|
||||
|
||||
runCoalescedFetch(t, cache, 8, &ProviderUsage{Provider: "anthropic", FetchedAt: time.Now()}, nil, &calls)
|
||||
|
||||
if got := calls.Load(); got != 1 {
|
||||
t.Errorf("concurrent misses fetched %d times, want 1", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUsageCacheCoalescesFailures(t *testing.T) {
|
||||
cache := NewUsageCache()
|
||||
var calls atomic.Int32
|
||||
|
||||
runCoalescedFetch(t, cache, 8, nil, errors.New("boom"), &calls)
|
||||
|
||||
if got := calls.Load(); got != 1 {
|
||||
t.Errorf("concurrent failing fetches called provider %d times, want 1", got)
|
||||
}
|
||||
fetch := func(_ context.Context) (*ProviderUsage, error) {
|
||||
calls.Add(1)
|
||||
return nil, errors.New("boom")
|
||||
}
|
||||
|
||||
// Once the negative entry ages past failureCacheTTL, the provider is retried.
|
||||
cache.mu.Lock()
|
||||
cache.entries["k"].errAt = time.Now().Add(-failureCacheTTL - time.Second)
|
||||
cache.mu.Unlock()
|
||||
if _, err := cache.GetOrFetchWithin(context.Background(), "k", freshMaxAge, fetch); err == nil {
|
||||
t.Error("expected error on retry after TTL")
|
||||
}
|
||||
if got := calls.Load(); got != 2 {
|
||||
t.Errorf("expired failure entry should refetch, calls = %d, want 2", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUsageCacheFailureKeepsStaleSuccess(t *testing.T) {
|
||||
cache := NewUsageCache()
|
||||
okUsage := &ProviderUsage{Provider: "anthropic", FetchedAt: time.Now()}
|
||||
okFetch := func(_ context.Context) (*ProviderUsage, error) { return okUsage, nil }
|
||||
failFetch := func(_ context.Context) (*ProviderUsage, error) { return nil, errors.New("boom") }
|
||||
|
||||
if _, err := cache.GetOrFetchWithin(context.Background(), "k", freshMaxAge, okFetch); err != nil {
|
||||
t.Fatalf("seed fetch: %v", err)
|
||||
}
|
||||
// Age the success past the fresh clamp but within the regular TTL.
|
||||
cache.mu.Lock()
|
||||
cache.entries["k"].successAt = time.Now().Add(-freshMaxAge - time.Second)
|
||||
cache.mu.Unlock()
|
||||
|
||||
// A failed fresh refresh returns the error to the fresh caller...
|
||||
if _, err := cache.GetOrFetchWithin(context.Background(), "k", freshMaxAge, failFetch); err == nil {
|
||||
t.Fatal("expected error from failed fresh fetch")
|
||||
}
|
||||
// ...but must not evict the stale-but-valid success for non-fresh callers.
|
||||
got, err := cache.GetOrFetchWithin(context.Background(), "k", cacheTTL, failFetch)
|
||||
if err != nil || got != okUsage {
|
||||
t.Errorf("stale success was evicted: usage=%v err=%v", got, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUsageCacheDoesNotCacheCancellation(t *testing.T) {
|
||||
cache := NewUsageCache()
|
||||
var calls atomic.Int32
|
||||
fetch := func(ctx context.Context) (*ProviderUsage, error) {
|
||||
calls.Add(1)
|
||||
return nil, ctx.Err()
|
||||
}
|
||||
|
||||
cancelled, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
if _, err := cache.GetOrFetchWithin(cancelled, "k", freshMaxAge, fetch); err == nil {
|
||||
t.Fatal("expected cancellation error")
|
||||
}
|
||||
|
||||
// The cancellation must not have been recorded as a provider failure.
|
||||
okUsage := &ProviderUsage{Provider: "anthropic", FetchedAt: time.Now()}
|
||||
got, err := cache.GetOrFetchWithin(context.Background(), "k", freshMaxAge,
|
||||
func(_ context.Context) (*ProviderUsage, error) { return okUsage, nil })
|
||||
if err != nil || got != okUsage {
|
||||
t.Errorf("cancelled fetch poisoned the cache: usage=%v err=%v", got, err)
|
||||
}
|
||||
if calls.Load() != 1 {
|
||||
t.Errorf("fetch calls = %d, want 1", calls.Load())
|
||||
}
|
||||
}
|
||||
@@ -13,7 +13,6 @@ import (
|
||||
"github.com/kandev/kandev/internal/agent/hostutility"
|
||||
"github.com/kandev/kandev/internal/agent/registry"
|
||||
agentsettingscontroller "github.com/kandev/kandev/internal/agent/settings/controller"
|
||||
agentusage "github.com/kandev/kandev/internal/agent/usage"
|
||||
agentctlutil "github.com/kandev/kandev/internal/agentctl/server/utility"
|
||||
analyticsservice "github.com/kandev/kandev/internal/analytics/service"
|
||||
"github.com/kandev/kandev/internal/automation"
|
||||
@@ -53,7 +52,6 @@ func provideServices(cfg *config.Config, log *logger.Logger, repos *Repositories
|
||||
return nil, nil, err
|
||||
}
|
||||
agentSettingsController := agentsettingscontroller.NewController(repos.AgentSettings, discoveryRegistry, agentRegistry, repos.Task, log)
|
||||
agentSettingsController.SetHostUsageLister(agentusage.NewHostService(log))
|
||||
|
||||
userSvc := userservice.NewService(repos.User, eventBus, log)
|
||||
editorSvc := editorservice.NewService(repos.Editor, repos.Task, userSvc)
|
||||
|
||||
@@ -11,7 +11,7 @@ import { toast } from "sonner";
|
||||
import { useAppStore } from "@/components/state-provider";
|
||||
import { updateAgentProfile, getAgentUtilization } from "@/lib/api/domains/office-api";
|
||||
import type { AgentProfile, AgentRole, ProviderUsage } from "@/lib/state/slices/office/types";
|
||||
import { UtilizationBars } from "@/components/usage/utilization-bars";
|
||||
import { UtilizationBars } from "@/app/office/components/utilization-bars";
|
||||
|
||||
type AgentOverviewTabProps = {
|
||||
agent: AgentProfile;
|
||||
|
||||
+5
-5
@@ -1,22 +1,22 @@
|
||||
"use client";
|
||||
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@kandev/ui/tooltip";
|
||||
import type { ProviderUsage } from "@/lib/types/agent-profile";
|
||||
import type { ProviderUsage } from "@/lib/state/slices/office/types";
|
||||
|
||||
type Props = {
|
||||
usage: ProviderUsage;
|
||||
};
|
||||
|
||||
export function getBarColor(pct: number): string {
|
||||
function getBarColor(pct: number): string {
|
||||
if (pct >= 90) return "bg-red-500";
|
||||
if (pct >= 80) return "bg-amber-500";
|
||||
return "bg-emerald-500";
|
||||
return "bg-blue-500";
|
||||
}
|
||||
|
||||
export function getTextColor(pct: number): string {
|
||||
function getTextColor(pct: number): string {
|
||||
if (pct >= 90) return "text-red-600 dark:text-red-400";
|
||||
if (pct >= 80) return "text-amber-600 dark:text-amber-400";
|
||||
return "text-emerald-600 dark:text-emerald-400";
|
||||
return "text-muted-foreground";
|
||||
}
|
||||
|
||||
function formatResetTime(resetAt: string): string {
|
||||
@@ -23,7 +23,7 @@ import { AgentCardsPanel } from "./components/agent-cards-panel";
|
||||
import { ProviderHealthCard } from "./components/routing/provider-health-card";
|
||||
import { timeAgo } from "@/lib/utils/time";
|
||||
|
||||
import { UtilizationBars } from "@/components/usage/utilization-bars";
|
||||
import { UtilizationBars } from "./components/utilization-bars";
|
||||
import { formatDollars } from "@/lib/utils";
|
||||
|
||||
// formatMonthSpend renders the subcents value from /office dashboard
|
||||
|
||||
@@ -31,7 +31,6 @@ import { useAgentDiscovery } from "@/hooks/domains/settings/use-agent-discovery"
|
||||
import { useAvailableAgents } from "@/hooks/domains/settings/use-available-agents";
|
||||
import { AgentLogo } from "@/components/agent-logo";
|
||||
import { AddTUIAgentDialog } from "@/components/settings/add-tui-agent-dialog";
|
||||
import { AgentUsageSection } from "@/components/settings/agent-usage-section";
|
||||
import { HostShellDialog } from "@/components/settings/host-shell-dialog";
|
||||
import { InstallAgentCard } from "@/components/settings/install-agent-card";
|
||||
import { InstalledAgentCard } from "@/components/settings/installed-agent-card";
|
||||
@@ -563,8 +562,6 @@ export default function AgentsSettingsPage() {
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<AgentUsageSection />
|
||||
|
||||
<Separator />
|
||||
|
||||
<InstalledAgentsSection
|
||||
|
||||
@@ -1,85 +0,0 @@
|
||||
import { cleanup, render, screen, waitFor } from "@testing-library/react";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { TooltipProvider } from "@kandev/ui/tooltip";
|
||||
import { AgentUsageSection } from "@/components/settings/agent-usage-section";
|
||||
|
||||
const listAgentSubscriptionUsage = vi.hoisted(() =>
|
||||
vi.fn<typeof import("@/lib/api").listAgentSubscriptionUsage>(),
|
||||
);
|
||||
|
||||
vi.mock("@/lib/api", () => ({
|
||||
listAgentSubscriptionUsage,
|
||||
}));
|
||||
|
||||
vi.mock("@/components/agent-logo", () => ({
|
||||
AgentLogo: ({ agentName, className }: { agentName: string; className?: string }) => (
|
||||
<span data-testid={`logo-${agentName}`} className={className} />
|
||||
),
|
||||
}));
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
listAgentSubscriptionUsage.mockReset();
|
||||
});
|
||||
|
||||
describe("AgentUsageSection", () => {
|
||||
it("renders nothing when no subscription agents are present", async () => {
|
||||
listAgentSubscriptionUsage.mockResolvedValue({ agents: [] });
|
||||
|
||||
const { container } = render(<AgentUsageSection />);
|
||||
|
||||
await waitFor(() => expect(listAgentSubscriptionUsage).toHaveBeenCalled());
|
||||
expect(container.querySelector('[data-testid="agent-usage-section"]')).toBeNull();
|
||||
});
|
||||
|
||||
it("renders utilization windows, plan badge, and per-agent errors", async () => {
|
||||
listAgentSubscriptionUsage.mockResolvedValue({
|
||||
agents: [
|
||||
{
|
||||
agent_id: "claude-acp",
|
||||
display_name: "Claude Code",
|
||||
usage: {
|
||||
provider: "anthropic",
|
||||
plan: "max",
|
||||
windows: [
|
||||
{
|
||||
label: "5-hour",
|
||||
utilization_pct: 62,
|
||||
reset_at: new Date(Date.now() + 3600_000).toISOString(),
|
||||
},
|
||||
{
|
||||
label: "7-day",
|
||||
utilization_pct: 15,
|
||||
reset_at: new Date(Date.now() + 86400_000).toISOString(),
|
||||
},
|
||||
],
|
||||
fetched_at: new Date().toISOString(),
|
||||
},
|
||||
},
|
||||
{
|
||||
agent_id: "codex-acp",
|
||||
display_name: "Codex",
|
||||
error: "codex usage: unexpected status 500",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
render(
|
||||
<TooltipProvider>
|
||||
<AgentUsageSection />
|
||||
</TooltipProvider>,
|
||||
);
|
||||
|
||||
await screen.findByTestId("agent-usage-section");
|
||||
expect(screen.getByText("Claude Code")).toBeDefined();
|
||||
expect(screen.getByText("max")).toBeDefined();
|
||||
expect(screen.getByText("Good")).toBeDefined(); // worst window 62% → Good
|
||||
expect(screen.getByText("5h")).toBeDefined();
|
||||
expect(screen.getByText("62%")).toBeDefined();
|
||||
expect(screen.getByText("7d")).toBeDefined();
|
||||
expect(screen.getByText("Codex")).toBeDefined();
|
||||
expect(screen.getByTestId("logo-codex-acp").className).toContain("!opacity-100");
|
||||
expect(screen.getByTestId("logo-codex-acp").className).toContain("brightness-100");
|
||||
expect(screen.getByText("Could not fetch usage data.")).toBeDefined();
|
||||
});
|
||||
});
|
||||
@@ -1,89 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { IconAlertTriangle, IconLoader2, IconRefresh } from "@tabler/icons-react";
|
||||
import { Badge } from "@kandev/ui/badge";
|
||||
import { Button } from "@kandev/ui/button";
|
||||
import { Card, CardContent } from "@kandev/ui/card";
|
||||
import { Separator } from "@kandev/ui/separator";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { AgentLogo } from "@/components/agent-logo";
|
||||
import { UsageWindowRows, usageStatus } from "@/components/usage/usage-window-rows";
|
||||
import { useAgentSubscriptionUsage } from "@/hooks/domains/settings/use-agent-subscription-usage";
|
||||
import type { AgentSubscriptionUsage } from "@/lib/types/http";
|
||||
|
||||
function AgentUsageCard({ item }: { item: AgentSubscriptionUsage }) {
|
||||
const usage = item.usage;
|
||||
const status = usage && usage.windows.length > 0 ? usageStatus(usage) : null;
|
||||
return (
|
||||
<Card data-testid={`agent-usage-card-${item.agent_id}`}>
|
||||
<CardContent className="py-4 space-y-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<AgentLogo agentName={item.agent_id} className="shrink-0 !opacity-100 brightness-100" />
|
||||
<h4 className="font-medium">{item.display_name}</h4>
|
||||
{usage?.plan && (
|
||||
<Badge variant="secondary" className="capitalize">
|
||||
{usage.plan}
|
||||
</Badge>
|
||||
)}
|
||||
{status && (
|
||||
<span className={cn("ml-auto text-xs font-semibold", status.className)}>
|
||||
{status.label}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{item.error ? (
|
||||
<p className="flex items-center gap-1 text-xs text-muted-foreground">
|
||||
<IconAlertTriangle className="h-3.5 w-3.5 shrink-0" />
|
||||
Could not fetch usage data.
|
||||
</p>
|
||||
) : (
|
||||
usage && <UsageWindowRows usage={usage} />
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* "Subscription Usage" section on Settings > Agents. Shows rate-limit window
|
||||
* utilization for host agents authenticated with a subscription (OAuth) —
|
||||
* Claude Code and Codex. Hidden when no subscription-billed agent is present.
|
||||
*/
|
||||
export function AgentUsageSection() {
|
||||
const { items, loading, refresh } = useAgentSubscriptionUsage();
|
||||
if (items.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className="space-y-4" data-testid="agent-usage-section">
|
||||
<Separator />
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold">Subscription Usage</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Rate-limit utilization for agents signed in with a subscription plan.
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => void refresh()}
|
||||
disabled={loading}
|
||||
className="cursor-pointer"
|
||||
>
|
||||
{loading ? (
|
||||
<IconLoader2 className="h-4 w-4 mr-2 animate-spin" />
|
||||
) : (
|
||||
<IconRefresh className="h-4 w-4 mr-2" />
|
||||
)}
|
||||
Refresh
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{items.map((item) => (
|
||||
<AgentUsageCard key={item.agent_id} item={item} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,17 +1,12 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { cleanup, fireEvent, render } from "@testing-library/react";
|
||||
import { useSessionContextWindow } from "@/hooks/domains/session/use-session-context-window";
|
||||
import { useSessionAgentUsage } from "@/hooks/domains/session/use-session-agent-usage";
|
||||
import { isContextWindowReliable, TokenUsageDisplay } from "./token-usage-display";
|
||||
|
||||
vi.mock("@/hooks/domains/session/use-session-context-window", () => ({
|
||||
useSessionContextWindow: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/hooks/domains/session/use-session-agent-usage", () => ({
|
||||
useSessionAgentUsage: vi.fn(() => null),
|
||||
}));
|
||||
|
||||
vi.mock("@kandev/ui/tooltip", () => ({
|
||||
TooltipProvider: ({ children }: { children: React.ReactNode }) => <>{children}</>,
|
||||
Tooltip: ({ children, open }: { children: React.ReactNode; open?: boolean }) => (
|
||||
@@ -94,74 +89,6 @@ describe("TokenUsageDisplay", () => {
|
||||
|
||||
expect(getByTestId("tooltip-root").getAttribute("data-open")).toBe("false");
|
||||
});
|
||||
|
||||
it("shows subscription usage rows in the tooltip when the agent has them", () => {
|
||||
vi.mocked(useSessionContextWindow).mockReturnValue({
|
||||
size: 200_000,
|
||||
used: 56_047,
|
||||
remaining: 143_953,
|
||||
efficiency: 28,
|
||||
});
|
||||
vi.mocked(useSessionAgentUsage).mockReturnValue({
|
||||
agent_id: "claude-acp",
|
||||
display_name: "Claude",
|
||||
usage: {
|
||||
provider: "anthropic",
|
||||
plan: "max",
|
||||
windows: [
|
||||
{
|
||||
label: "5-hour",
|
||||
utilization_pct: 86,
|
||||
reset_at: new Date(Date.now() + 3 * 3_600_000).toISOString(),
|
||||
},
|
||||
{
|
||||
label: "7-day",
|
||||
utilization_pct: 19,
|
||||
reset_at: new Date(Date.now() + 30 * 3_600_000).toISOString(),
|
||||
},
|
||||
],
|
||||
fetched_at: new Date().toISOString(),
|
||||
},
|
||||
});
|
||||
|
||||
const { container, getByRole, getByTestId, getByText } = render(
|
||||
<TokenUsageDisplay sessionId="sess-1" />,
|
||||
);
|
||||
|
||||
expect(container.querySelector('[data-testid="doughnut-subscription-usage"]')).not.toBeNull();
|
||||
expect(getByRole("button", { name: "Context window: 28% used" })).toBeDefined();
|
||||
expect(getByText("Context window")).toBeDefined();
|
||||
expect(getByText("56.0K of 200.0K tokens")).toBeDefined();
|
||||
expect(getByText(/Subscription · max/i)).toBeDefined();
|
||||
expect(
|
||||
getByTestId("context-window-usage").compareDocumentPosition(
|
||||
getByTestId("doughnut-subscription-usage"),
|
||||
) & Node.DOCUMENT_POSITION_FOLLOWING,
|
||||
).toBeTruthy();
|
||||
// Worst window is 86% → provider status "High".
|
||||
expect(getByText("High")).toBeDefined();
|
||||
expect(getByText("5h")).toBeDefined();
|
||||
expect(getByText("86%")).toBeDefined();
|
||||
expect(getByText("7d")).toBeDefined();
|
||||
// Reset countdown column (≈3h out) and colored bars per window.
|
||||
expect(getByText(/^(3h 0m|2h 59m)$/)).toBeDefined();
|
||||
expect(container.querySelector(".bg-amber-500")).not.toBeNull(); // 86% bar
|
||||
expect(container.querySelector(".bg-emerald-500")).not.toBeNull(); // 19% bar
|
||||
});
|
||||
|
||||
it("omits the subscription block when the agent has no usage", () => {
|
||||
vi.mocked(useSessionContextWindow).mockReturnValue({
|
||||
size: 200_000,
|
||||
used: 56_047,
|
||||
remaining: 143_953,
|
||||
efficiency: 28,
|
||||
});
|
||||
vi.mocked(useSessionAgentUsage).mockReturnValue(null);
|
||||
|
||||
const { container } = render(<TokenUsageDisplay sessionId="sess-1" />);
|
||||
|
||||
expect(container.querySelector('[data-testid="doughnut-subscription-usage"]')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("TokenUsageDisplay context source", () => {
|
||||
|
||||
@@ -4,9 +4,7 @@ import { memo, useEffect, useId, useRef, useState } from "react";
|
||||
import { IconInfoCircle } from "@tabler/icons-react";
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@kandev/ui/tooltip";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { UsageWindowRows, usageStatus } from "@/components/usage/usage-window-rows";
|
||||
import { useSessionContextWindow } from "@/hooks/domains/session/use-session-context-window";
|
||||
import { useSessionAgentUsage } from "@/hooks/domains/session/use-session-agent-usage";
|
||||
|
||||
type TokenUsageDisplayProps = {
|
||||
sessionId: string | null;
|
||||
@@ -153,33 +151,6 @@ function ContextWindowSource({ source }: { source: "acp" | "api" | undefined })
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscription utilization rows for the session's agent, rendered inside the
|
||||
* doughnut tooltip. Mounted only while the tooltip is open, so each hover
|
||||
* triggers a fresh provider fetch (server-clamped to one per 15 s).
|
||||
*/
|
||||
function SessionUsageRows({ sessionId }: { sessionId: string | null }) {
|
||||
const agentUsage = useSessionAgentUsage(sessionId);
|
||||
const usage = agentUsage?.usage;
|
||||
if (!usage || usage.windows.length === 0) return null;
|
||||
const status = usageStatus(usage);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="pt-2 mt-2 border-t border-border/60 space-y-2 min-w-64 opacity-80"
|
||||
data-testid="doughnut-subscription-usage"
|
||||
>
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<span className="text-[10px] font-medium uppercase text-muted-foreground">
|
||||
Subscription{usage.plan ? ` · ${usage.plan}` : ""}
|
||||
</span>
|
||||
<span className={cn("text-[10px] font-medium", status.className)}>{status.label}</span>
|
||||
</div>
|
||||
<UsageWindowRows usage={usage} className="text-[11px]" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export const TokenUsageDisplay = memo(function TokenUsageDisplay({
|
||||
sessionId,
|
||||
className,
|
||||
@@ -248,7 +219,6 @@ export const TokenUsageDisplay = memo(function TokenUsageDisplay({
|
||||
<ContextWindowSource source={source} />
|
||||
</div>
|
||||
</div>
|
||||
<SessionUsageRows sessionId={sessionId} />
|
||||
</div>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
@@ -1,111 +0,0 @@
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { cleanup, render } from "@testing-library/react";
|
||||
import {
|
||||
UsageWindowRows,
|
||||
formatResetShort,
|
||||
shortWindowLabel,
|
||||
usageStatus,
|
||||
} from "./usage-window-rows";
|
||||
import type { ProviderUsage } from "@/lib/types/agent-profile";
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
describe("shortWindowLabel", () => {
|
||||
it("shortens hour and day windows", () => {
|
||||
expect(shortWindowLabel("5-hour")).toBe("5h");
|
||||
expect(shortWindowLabel("7-day")).toBe("7d");
|
||||
expect(shortWindowLabel("30-day")).toBe("30d");
|
||||
expect(shortWindowLabel("7-day (Opus)")).toBe("7d (Opus)");
|
||||
});
|
||||
|
||||
it("leaves unknown labels untouched", () => {
|
||||
expect(shortWindowLabel("primary")).toBe("primary");
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatResetShort", () => {
|
||||
const now = Date.UTC(2026, 6, 14, 12, 0, 0);
|
||||
|
||||
it("formats minutes", () => {
|
||||
expect(formatResetShort(new Date(now + 42 * 60_000).toISOString(), now)).toBe("42m");
|
||||
});
|
||||
|
||||
it("formats hours and minutes", () => {
|
||||
expect(formatResetShort(new Date(now + 3 * 3_600_000 + 17 * 60_000).toISOString(), now)).toBe(
|
||||
"3h 17m",
|
||||
);
|
||||
});
|
||||
|
||||
it("formats days and hours", () => {
|
||||
expect(formatResetShort(new Date(now + 29 * 3_600_000).toISOString(), now)).toBe("1d 5h");
|
||||
});
|
||||
|
||||
it("handles past reset times", () => {
|
||||
expect(formatResetShort(new Date(now - 60_000).toISOString(), now)).toBe("soon");
|
||||
});
|
||||
|
||||
it("handles invalid timestamps", () => {
|
||||
expect(formatResetShort("not-a-date", now)).toBe("soon");
|
||||
});
|
||||
});
|
||||
|
||||
function makeUsage(pcts: number[]): ProviderUsage {
|
||||
return {
|
||||
provider: "anthropic",
|
||||
plan: "max",
|
||||
windows: pcts.map((pct, i) => ({
|
||||
label: `${i + 1}-day`,
|
||||
utilization_pct: pct,
|
||||
reset_at: new Date(Date.now() + 3_600_000).toISOString(),
|
||||
})),
|
||||
fetched_at: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
describe("usageStatus", () => {
|
||||
it("is Good below 80%", () => {
|
||||
expect(usageStatus(makeUsage([10, 79])).label).toBe("Good");
|
||||
});
|
||||
|
||||
it("is High at 80–89%", () => {
|
||||
expect(usageStatus(makeUsage([10, 85])).label).toBe("High");
|
||||
});
|
||||
|
||||
it("is Critical at 90%+", () => {
|
||||
expect(usageStatus(makeUsage([10, 94])).label).toBe("Critical");
|
||||
});
|
||||
});
|
||||
|
||||
describe("UsageWindowRows", () => {
|
||||
it("renders a colored bar, percentage, and reset time per window", () => {
|
||||
const usage: ProviderUsage = {
|
||||
provider: "anthropic",
|
||||
plan: "max",
|
||||
windows: [
|
||||
{
|
||||
label: "5-hour",
|
||||
utilization_pct: 94,
|
||||
reset_at: new Date(Date.now() + 2 * 3_600_000).toISOString(),
|
||||
},
|
||||
{
|
||||
label: "7-day",
|
||||
utilization_pct: 39,
|
||||
// 30s under 5 days so the elapsed render time cannot flip the
|
||||
// formatted value between "5d 0h" and "4d 23h".
|
||||
reset_at: new Date(Date.now() + 5 * 24 * 3_600_000 - 30_000).toISOString(),
|
||||
},
|
||||
],
|
||||
fetched_at: new Date().toISOString(),
|
||||
};
|
||||
|
||||
const { container, getByText } = render(<UsageWindowRows usage={usage} />);
|
||||
|
||||
expect(getByText("5h")).toBeDefined();
|
||||
expect(getByText("94%")).toBeDefined();
|
||||
expect(getByText("7d")).toBeDefined();
|
||||
expect(getByText("39%")).toBeDefined();
|
||||
expect(getByText("4d 23h")).toBeDefined();
|
||||
expect(container.querySelector(".bg-red-500")).not.toBeNull();
|
||||
expect(container.querySelector(".bg-emerald-500")).not.toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -1,86 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { Fragment } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { ProviderUsage, UtilizationWindow } from "@/lib/types/agent-profile";
|
||||
import { getBarColor, getTextColor } from "./utilization-bars";
|
||||
|
||||
/** "5-hour" → "5h", "7-day (Opus)" → "7d (Opus)". */
|
||||
export function shortWindowLabel(label: string): string {
|
||||
return label.replace(/(\d+)-hour/i, "$1h").replace(/(\d+)-day/i, "$1d");
|
||||
}
|
||||
|
||||
/** Compact time-until-reset: "3h 3m", "5d 12h", "42m", "soon". */
|
||||
export function formatResetShort(resetAt: string, now = Date.now()): string {
|
||||
const diffMs = new Date(resetAt).getTime() - now;
|
||||
if (!Number.isFinite(diffMs) || diffMs <= 0) return "soon";
|
||||
const totalHours = Math.floor(diffMs / 3_600_000);
|
||||
const minutes = Math.floor((diffMs % 3_600_000) / 60_000);
|
||||
if (totalHours >= 24) return `${Math.floor(totalHours / 24)}d ${totalHours % 24}h`;
|
||||
if (totalHours > 0) return `${totalHours}h ${minutes}m`;
|
||||
return `${minutes}m`;
|
||||
}
|
||||
|
||||
export type UsageStatus = {
|
||||
label: "Good" | "High" | "Critical";
|
||||
className: string;
|
||||
};
|
||||
|
||||
/** Worst-window health for a provider, rendered like "Good" / "Critical". */
|
||||
export function usageStatus(usage: ProviderUsage): UsageStatus {
|
||||
const worst = Math.max(0, ...usage.windows.map((w) => w.utilization_pct));
|
||||
if (worst >= 90) return { label: "Critical", className: "text-red-600 dark:text-red-400" };
|
||||
if (worst >= 80) return { label: "High", className: "text-amber-600 dark:text-amber-400" };
|
||||
return { label: "Good", className: "text-emerald-600 dark:text-emerald-400" };
|
||||
}
|
||||
|
||||
function UsageWindowCells({ window: w }: { window: UtilizationWindow }) {
|
||||
const pct = Math.min(100, Math.max(0, w.utilization_pct));
|
||||
return (
|
||||
<>
|
||||
<span className="text-muted-foreground whitespace-nowrap">{shortWindowLabel(w.label)}</span>
|
||||
<div className="h-1.5 min-w-20 rounded-full bg-muted overflow-hidden">
|
||||
<div
|
||||
className={cn("h-full rounded-full transition-all", getBarColor(pct))}
|
||||
style={{ width: `${pct}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className={cn("font-semibold tabular-nums text-right", getTextColor(pct))}>
|
||||
{Math.round(pct)}%
|
||||
</span>
|
||||
<span
|
||||
className="text-muted-foreground/70 tabular-nums text-right whitespace-nowrap"
|
||||
title={`Resets in ${formatResetShort(w.reset_at)}`}
|
||||
>
|
||||
{formatResetShort(w.reset_at)}
|
||||
</span>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Compact per-window utilization rows: `5h [==== ] 39% 3h 3m`.
|
||||
* Shared by the settings usage cards and the chat doughnut tooltip.
|
||||
*/
|
||||
export function UsageWindowRows({
|
||||
usage,
|
||||
className,
|
||||
}: {
|
||||
usage: ProviderUsage;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"grid grid-cols-[auto_1fr_auto_auto] items-center gap-x-3 gap-y-2 text-xs",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{usage.windows.map((w) => (
|
||||
<Fragment key={w.label}>
|
||||
<UsageWindowCells window={w} />
|
||||
</Fragment>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,76 +0,0 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useAppStore } from "@/components/state-provider";
|
||||
import { listAgentSubscriptionUsage } from "@/lib/api";
|
||||
import type { AgentSubscriptionUsage } from "@/lib/types/http";
|
||||
|
||||
// Module-level memo: the last usage listing, served instantly on the next
|
||||
// tooltip open while a fresh fetch is in flight. One in-flight request is
|
||||
// shared between concurrent consumers.
|
||||
let lastAgents: AgentSubscriptionUsage[] | null = null;
|
||||
let inflight: Promise<AgentSubscriptionUsage[]> | null = null;
|
||||
|
||||
function fetchFreshAgentUsage(): Promise<AgentSubscriptionUsage[]> {
|
||||
if (!inflight) {
|
||||
inflight = listAgentSubscriptionUsage({ cache: "no-store", fresh: true })
|
||||
.then((response) => {
|
||||
lastAgents = response.agents ?? [];
|
||||
return lastAgents;
|
||||
})
|
||||
.finally(() => {
|
||||
inflight = null;
|
||||
});
|
||||
}
|
||||
return inflight;
|
||||
}
|
||||
|
||||
/** Test-only: clear the module-level memo between tests. */
|
||||
export function resetSessionAgentUsageCacheForTest() {
|
||||
lastAgents = null;
|
||||
inflight = null;
|
||||
}
|
||||
|
||||
/** Resolves the session's agent name (e.g. "claude-acp") from the store. */
|
||||
export function useSessionAgentName(sessionId: string | null): string | null {
|
||||
const profileId = useAppStore((state) =>
|
||||
sessionId ? state.taskSessions.items[sessionId]?.agent_profile_id : undefined,
|
||||
);
|
||||
const agentName = useAppStore((state) =>
|
||||
profileId
|
||||
? state.agentProfiles.items.find((profile) => profile.id === profileId)?.agent_name
|
||||
: undefined,
|
||||
);
|
||||
return agentName ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Live subscription usage for the session's agent. Fetches fresh provider
|
||||
* data on mount — mount the consuming component lazily (e.g. inside a tooltip
|
||||
* content) so hovering triggers the fetch. The previous listing renders
|
||||
* immediately while the fresh one is in flight. Returns null while the agent
|
||||
* is unknown or has no subscription usage.
|
||||
*/
|
||||
export function useSessionAgentUsage(sessionId: string | null): AgentSubscriptionUsage | null {
|
||||
const agentName = useSessionAgentName(sessionId);
|
||||
const [agents, setAgents] = useState<AgentSubscriptionUsage[]>(lastAgents ?? []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!agentName) return;
|
||||
let active = true;
|
||||
fetchFreshAgentUsage()
|
||||
.then((list) => {
|
||||
if (active) setAgents(list);
|
||||
})
|
||||
.catch(() => {
|
||||
// Don't keep presenting stale usage as live: drop the memo so the
|
||||
// next open refetches, and hide the rows for this open.
|
||||
lastAgents = null;
|
||||
if (active) setAgents([]);
|
||||
});
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
}, [agentName]);
|
||||
|
||||
if (!agentName) return null;
|
||||
return agents.find((agent) => agent.agent_id === agentName) ?? null;
|
||||
}
|
||||
@@ -1,38 +0,0 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { listAgentSubscriptionUsage } from "@/lib/api";
|
||||
import type { AgentSubscriptionUsage } from "@/lib/types/http";
|
||||
|
||||
/**
|
||||
* Fetches subscription utilization for host-installed agents (Claude Code,
|
||||
* Codex). The initial load accepts the backend's 5-minute cache; manual
|
||||
* refresh() requests fresh provider data (server-clamped to 15 s).
|
||||
*/
|
||||
export function useAgentSubscriptionUsage() {
|
||||
const [items, setItems] = useState<AgentSubscriptionUsage[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const fetchingRef = useRef(false);
|
||||
|
||||
const fetchUsage = useCallback(async (fresh: boolean) => {
|
||||
if (fetchingRef.current) return;
|
||||
fetchingRef.current = true;
|
||||
setLoading(true);
|
||||
try {
|
||||
const response = await listAgentSubscriptionUsage({ cache: "no-store", fresh });
|
||||
setItems(response.agents ?? []);
|
||||
} catch {
|
||||
// Keep the previous items: clearing them would hide the whole section
|
||||
// (including the Refresh button) on a transient refresh failure.
|
||||
} finally {
|
||||
fetchingRef.current = false;
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const refresh = useCallback(() => fetchUsage(true), [fetchUsage]);
|
||||
|
||||
useEffect(() => {
|
||||
void fetchUsage(false);
|
||||
}, [fetchUsage]);
|
||||
|
||||
return { items, loading, refresh };
|
||||
}
|
||||
@@ -2,7 +2,6 @@ import { fetchJson, fetchJsonWithRetry, type ApiRequestOptions } from "../client
|
||||
import { getBackendConfig } from "@/lib/config";
|
||||
import type {
|
||||
Agent,
|
||||
AgentSubscriptionUsageResponse,
|
||||
ListAgentsResponse,
|
||||
ListAgentDiscoveryResponse,
|
||||
ListAvailableAgentsResponse,
|
||||
@@ -216,14 +215,6 @@ export async function listAvailableAgents(
|
||||
return fetchJson<ListAvailableAgentsResponse>("/api/v1/agents/available", options);
|
||||
}
|
||||
|
||||
export async function listAgentSubscriptionUsage(
|
||||
options?: ApiRequestOptions & { fresh?: boolean },
|
||||
): Promise<AgentSubscriptionUsageResponse> {
|
||||
const { fresh, ...rest } = options ?? {};
|
||||
const path = fresh ? "/api/v1/agents/usage?fresh=true" : "/api/v1/agents/usage";
|
||||
return fetchJson<AgentSubscriptionUsageResponse>(path, rest);
|
||||
}
|
||||
|
||||
export async function getAgentProfileMcpConfig(
|
||||
profileId: string,
|
||||
options?: ApiRequestOptions,
|
||||
|
||||
@@ -36,22 +36,10 @@ export type UtilizationWindow = {
|
||||
|
||||
export type ProviderUsage = {
|
||||
provider: string;
|
||||
plan?: string;
|
||||
windows: UtilizationWindow[];
|
||||
fetched_at: string;
|
||||
};
|
||||
|
||||
export type AgentSubscriptionUsage = {
|
||||
agent_id: string;
|
||||
display_name: string;
|
||||
usage?: ProviderUsage | null;
|
||||
error?: string;
|
||||
};
|
||||
|
||||
export type AgentSubscriptionUsageResponse = {
|
||||
agents: AgentSubscriptionUsage[];
|
||||
};
|
||||
|
||||
export type AgentRole =
|
||||
| "ceo"
|
||||
| "worker"
|
||||
|
||||
@@ -10,8 +10,6 @@ export type {
|
||||
AgentProfilePayload,
|
||||
AgentRole,
|
||||
AgentStatus,
|
||||
AgentSubscriptionUsage,
|
||||
AgentSubscriptionUsageResponse,
|
||||
BillingType,
|
||||
CLIFlag,
|
||||
UtilizationWindow,
|
||||
|
||||
Reference in New Issue
Block a user