From 78d0fe6d6f86851985f5731ae55d1333b3e318d1 Mon Sep 17 00:00:00 2001 From: Peter Kirkham Date: Tue, 26 May 2026 12:53:19 +0100 Subject: [PATCH 1/3] feat(billing): rework usage tracking + context breakdown --- apps/code/src/main/di/container.ts | 2 + apps/code/src/main/di/tokens.ts | 1 + apps/code/src/main/services/agent/schemas.ts | 4 + apps/code/src/main/services/agent/service.ts | 4 + .../src/main/services/llm-gateway/schemas.ts | 4 + .../src/main/services/llm-gateway/service.ts | 11 +- .../main/services/usage-monitor/schemas.ts | 35 +++ .../services/usage-monitor/service.test.ts | 284 +++++++++++++++++ .../main/services/usage-monitor/service.ts | 287 ++++++++++++++++++ .../src/main/services/usage-monitor/store.ts | 17 ++ apps/code/src/main/trpc/router.ts | 2 + .../code/src/main/trpc/routers/llm-gateway.ts | 10 +- .../src/main/trpc/routers/usage-monitor.ts | 36 +++ apps/code/src/renderer/App.tsx | 7 + .../src/renderer/components/MainLayout.tsx | 2 - .../billing/components/SidebarUsageBar.tsx | 44 ++- .../features/billing/hooks/useFreeUsage.ts | 18 +- .../features/billing/hooks/useUsage.ts | 51 +++- .../billing/hooks/useUsageLimitDetection.ts | 38 --- .../features/billing/usageThresholdToast.ts | 65 ++++ .../renderer/features/billing/utils.test.ts | 35 ++- .../src/renderer/features/billing/utils.ts | 35 +++ .../ContextBreakdownPopover.test.tsx | 65 ++++ .../components/ContextBreakdownPopover.tsx | 117 +++++++ .../components/ContextUsageIndicator.tsx | 107 ++++--- .../sessions/hooks/useContextUsage.test.ts | 79 +++++ .../sessions/hooks/useContextUsage.ts | 110 +++++-- .../features/sessions/utils/contextColors.ts | 33 ++ .../components/sections/PlanUsageSettings.tsx | 14 +- apps/code/src/renderer/utils/toast.tsx | 8 +- .../agent/src/adapters/claude/claude-agent.ts | 68 ++++- .../adapters/claude/context-breakdown.test.ts | 130 ++++++++ .../src/adapters/claude/context-breakdown.ts | 143 +++++++++ .../src/adapters/claude/mcp/tool-metadata.ts | 6 + packages/agent/src/adapters/claude/types.ts | 5 + .../agent/src/adapters/codex/codex-agent.ts | 59 +++- .../src/adapters/codex/codex-client.test.ts | 50 ++- .../agent/src/adapters/codex/session-state.ts | 43 +++ 38 files changed, 1838 insertions(+), 191 deletions(-) create mode 100644 apps/code/src/main/services/usage-monitor/schemas.ts create mode 100644 apps/code/src/main/services/usage-monitor/service.test.ts create mode 100644 apps/code/src/main/services/usage-monitor/service.ts create mode 100644 apps/code/src/main/services/usage-monitor/store.ts create mode 100644 apps/code/src/main/trpc/routers/usage-monitor.ts delete mode 100644 apps/code/src/renderer/features/billing/hooks/useUsageLimitDetection.ts create mode 100644 apps/code/src/renderer/features/billing/usageThresholdToast.ts create mode 100644 apps/code/src/renderer/features/sessions/components/ContextBreakdownPopover.test.tsx create mode 100644 apps/code/src/renderer/features/sessions/components/ContextBreakdownPopover.tsx create mode 100644 apps/code/src/renderer/features/sessions/hooks/useContextUsage.test.ts create mode 100644 apps/code/src/renderer/features/sessions/utils/contextColors.ts create mode 100644 packages/agent/src/adapters/claude/context-breakdown.test.ts create mode 100644 packages/agent/src/adapters/claude/context-breakdown.ts diff --git a/apps/code/src/main/di/container.ts b/apps/code/src/main/di/container.ts index 5b1fababfe..b2e2379419 100644 --- a/apps/code/src/main/di/container.ts +++ b/apps/code/src/main/di/container.ts @@ -67,6 +67,7 @@ import { SuspensionService } from "../services/suspension/service"; import { TaskLinkService } from "../services/task-link/service"; import { UIService } from "../services/ui/service"; import { UpdatesService } from "../services/updates/service"; +import { UsageMonitorService } from "../services/usage-monitor/service"; import { WatcherRegistryService } from "../services/watcher-registry/service"; import { WorkspaceService } from "../services/workspace/service"; import { MAIN_TOKENS } from "./tokens"; @@ -147,6 +148,7 @@ container.bind(MAIN_TOKENS.ShellService).to(ShellService); container.bind(MAIN_TOKENS.SlackIntegrationService).to(SlackIntegrationService); container.bind(MAIN_TOKENS.UIService).to(UIService); container.bind(MAIN_TOKENS.UpdatesService).to(UpdatesService); +container.bind(MAIN_TOKENS.UsageMonitorService).to(UsageMonitorService); container.bind(MAIN_TOKENS.TaskLinkService).to(TaskLinkService); container.bind(MAIN_TOKENS.InboxLinkService).to(InboxLinkService); container.bind(MAIN_TOKENS.NewTaskLinkService).to(NewTaskLinkService); diff --git a/apps/code/src/main/di/tokens.ts b/apps/code/src/main/di/tokens.ts index c754b589ea..69ea894b37 100644 --- a/apps/code/src/main/di/tokens.ts +++ b/apps/code/src/main/di/tokens.ts @@ -83,4 +83,5 @@ export const MAIN_TOKENS = Object.freeze({ ProvisioningService: Symbol.for("Main.ProvisioningService"), WorkspaceService: Symbol.for("Main.WorkspaceService"), EnrichmentService: Symbol.for("Main.EnrichmentService"), + UsageMonitorService: Symbol.for("Main.UsageMonitorService"), }); diff --git a/apps/code/src/main/services/agent/schemas.ts b/apps/code/src/main/services/agent/schemas.ts index 3ead6cf15b..95b09dc0e6 100644 --- a/apps/code/src/main/services/agent/schemas.ts +++ b/apps/code/src/main/services/agent/schemas.ts @@ -203,6 +203,9 @@ export const AgentServiceEvent = { SessionsIdle: "sessions-idle", SessionIdleKilled: "session-idle-killed", AgentFileActivity: "agent-file-activity", + // Fires once per completed turn for both adapters. Consumed by + // UsageMonitorService to refresh billing usage without polling. + LlmActivity: "llm-activity", } as const; export interface AgentSessionEventPayload { @@ -234,6 +237,7 @@ export interface AgentServiceEvents { [AgentServiceEvent.SessionsIdle]: undefined; [AgentServiceEvent.SessionIdleKilled]: SessionIdleKilledPayload; [AgentServiceEvent.AgentFileActivity]: AgentFileActivityPayload; + [AgentServiceEvent.LlmActivity]: undefined; } // Permission response input for tRPC diff --git a/apps/code/src/main/services/agent/service.ts b/apps/code/src/main/services/agent/service.ts index e111136ce6..1596f9ff5b 100644 --- a/apps/code/src/main/services/agent/service.ts +++ b/apps/code/src/main/services/agent/service.ts @@ -1481,6 +1481,10 @@ For git operations while detached: } } + if (isNotification(method, POSTHOG_NOTIFICATIONS.USAGE_UPDATE)) { + this.emit(AgentServiceEvent.LlmActivity, undefined); + } + // Extension notifications already flow through the tapped stream // (same pattern as sessionUpdate). No need to re-emit here. }, diff --git a/apps/code/src/main/services/llm-gateway/schemas.ts b/apps/code/src/main/services/llm-gateway/schemas.ts index 8268e9067b..06a735ca5d 100644 --- a/apps/code/src/main/services/llm-gateway/schemas.ts +++ b/apps/code/src/main/services/llm-gateway/schemas.ts @@ -60,6 +60,9 @@ export interface AnthropicErrorResponse { export const usageBucketSchema = z.object({ used_percent: z.number(), resets_in_seconds: z.number(), + // Absolute UTC reset timestamp from gateway A1; preferred over the + // rolling resets_in_seconds, which drifts between polls. + reset_at: z.string().datetime().optional(), exceeded: z.boolean(), }); @@ -69,6 +72,7 @@ export const usageOutput = z.object({ sustained: usageBucketSchema, burst: usageBucketSchema, is_rate_limited: z.boolean(), + billing_period_end: z.string().datetime().nullable().optional(), }); export type UsageBucket = z.infer; diff --git a/apps/code/src/main/services/llm-gateway/service.ts b/apps/code/src/main/services/llm-gateway/service.ts index 6f2ac87e93..2a6ac5a266 100644 --- a/apps/code/src/main/services/llm-gateway/service.ts +++ b/apps/code/src/main/services/llm-gateway/service.ts @@ -175,9 +175,18 @@ export class LlmGatewayService { log.debug("Fetching usage from gateway", { url: usageUrl }); - const response = await this.authService.authenticatedFetch(fetch, usageUrl); + let response: Response; + try { + response = await this.authService.authenticatedFetch(fetch, usageUrl); + } catch (err) { + log.warn("Usage fetch network error", { + error: err instanceof Error ? err.message : String(err), + }); + throw err; + } if (!response.ok) { + log.warn("Usage fetch failed", { status: response.status }); throw new LlmGatewayError( `Failed to fetch usage: HTTP ${response.status}`, "usage_error", diff --git a/apps/code/src/main/services/usage-monitor/schemas.ts b/apps/code/src/main/services/usage-monitor/schemas.ts new file mode 100644 index 0000000000..f3d3f7e456 --- /dev/null +++ b/apps/code/src/main/services/usage-monitor/schemas.ts @@ -0,0 +1,35 @@ +import type { UsageOutput } from "@main/services/llm-gateway/schemas"; +import { usageOutput } from "@main/services/llm-gateway/schemas"; +import { z } from "zod"; + +export const USAGE_THRESHOLDS = [50, 75, 90, 100] as const; +export type UsageThreshold = (typeof USAGE_THRESHOLDS)[number]; + +export const thresholdCrossedEvent = z.object({ + bucket: z.enum(["burst", "sustained"]), + threshold: z.union([ + z.literal(50), + z.literal(75), + z.literal(90), + z.literal(100), + ]), + usedPercent: z.number(), + resetAt: z.string().datetime().nullable(), + resetsInSeconds: z.number(), + isPro: z.boolean(), +}); + +export type ThresholdCrossedEvent = z.infer; + +export const usageSnapshotOutput = usageOutput.nullable(); +export type UsageSnapshot = UsageOutput | null; + +export const UsageMonitorEvent = { + ThresholdCrossed: "threshold-crossed", + UsageUpdated: "usage-updated", +} as const; + +export interface UsageMonitorEvents { + [UsageMonitorEvent.ThresholdCrossed]: ThresholdCrossedEvent; + [UsageMonitorEvent.UsageUpdated]: UsageOutput; +} diff --git a/apps/code/src/main/services/usage-monitor/service.test.ts b/apps/code/src/main/services/usage-monitor/service.test.ts new file mode 100644 index 0000000000..fbff9a80da --- /dev/null +++ b/apps/code/src/main/services/usage-monitor/service.test.ts @@ -0,0 +1,284 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { UsageOutput } from "../llm-gateway/schemas"; +import { UsageMonitorEvent } from "./schemas"; + +const mockStoreGet = vi.hoisted(() => vi.fn()); +const mockStoreSet = vi.hoisted(() => vi.fn()); + +vi.mock("./store", () => ({ + usageMonitorStore: { + get: mockStoreGet, + set: mockStoreSet, + }, +})); + +vi.mock("../../utils/logger.js", () => ({ + logger: { + scope: () => ({ + info: vi.fn(), + error: vi.fn(), + warn: vi.fn(), + debug: vi.fn(), + }), + }, +})); + +import { TypedEventEmitter } from "../../utils/typed-event-emitter"; +import { AgentServiceEvent } from "../agent/schemas"; +import type { AgentService } from "../agent/service"; +import type { LlmGatewayService } from "../llm-gateway/service"; +import { UsageMonitorService } from "./service"; + +function makeAgentService() { + return new TypedEventEmitter<{ + [AgentServiceEvent.LlmActivity]: undefined; + }>() as unknown as AgentService; +} + +function makeUsage(overrides?: { + burstPercent?: number; + sustainedPercent?: number; + billingPeriodEnd?: string | null; + burstResetAt?: string; + sustainedResetAt?: string; +}): UsageOutput { + return { + product: "posthog_code", + user_id: 42, + is_rate_limited: false, + billing_period_end: + overrides?.billingPeriodEnd === undefined + ? null + : overrides.billingPeriodEnd, + burst: { + used_percent: overrides?.burstPercent ?? 0, + resets_in_seconds: 3600, + reset_at: overrides?.burstResetAt ?? "2026-05-25T16:00:00.000Z", + exceeded: false, + }, + sustained: { + used_percent: overrides?.sustainedPercent ?? 0, + resets_in_seconds: 86400, + reset_at: overrides?.sustainedResetAt ?? "2026-06-01T00:00:00.000Z", + exceeded: false, + }, + }; +} + +function mockGateway(usage: UsageOutput | null): LlmGatewayService { + return { + fetchUsage: vi.fn().mockResolvedValue(usage), + } as unknown as LlmGatewayService; +} + +describe("UsageMonitorService", () => { + let service: UsageMonitorService; + let persisted: Record; + + beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-05-25T12:00:00.000Z")); + persisted = {}; + mockStoreGet.mockImplementation((_key: string, fallback: unknown) => ({ + ...persisted, + ...(fallback as Record), + })); + mockStoreSet.mockImplementation( + (_key: string, value: Record) => { + persisted = { ...value }; + }, + ); + }); + + afterEach(() => { + service?.stop(); + vi.useRealTimers(); + }); + + it("emits at 75% but not again on the next poll for the same anchor", async () => { + const events: unknown[] = []; + const gateway = mockGateway(makeUsage({ burstPercent: 78 })); + service = new UsageMonitorService(gateway, makeAgentService()); + service.on(UsageMonitorEvent.ThresholdCrossed, (e) => events.push(e)); + + await service.fetchOnce(); + expect(events).toHaveLength(1); + expect(events[0]).toMatchObject({ + bucket: "burst", + threshold: 75, + usedPercent: 78, + }); + + await service.fetchOnce(); + expect(events).toHaveLength(1); + }); + + it("only emits the highest threshold a bucket has crossed", async () => { + const events: unknown[] = []; + const gateway = mockGateway(makeUsage({ burstPercent: 95 })); + service = new UsageMonitorService(gateway, makeAgentService()); + service.on(UsageMonitorEvent.ThresholdCrossed, (e) => events.push(e)); + + await service.fetchOnce(); + expect(events).toHaveLength(1); + expect(events[0]).toMatchObject({ threshold: 90 }); + }); + + it("doesn't re-emit after a relaunch with persisted dedupe", async () => { + const events: unknown[] = []; + const gateway = mockGateway(makeUsage({ burstPercent: 55 })); + service = new UsageMonitorService(gateway, makeAgentService()); + service.on(UsageMonitorEvent.ThresholdCrossed, (e) => events.push(e)); + await service.fetchOnce(); + expect(events).toHaveLength(1); + service.stop(); + + // Simulate relaunch + service = new UsageMonitorService(gateway, makeAgentService()); + service.on(UsageMonitorEvent.ThresholdCrossed, (e) => events.push(e)); + await service.fetchOnce(); + expect(events).toHaveLength(1); + }); + + it("tracks burst and sustained as independent buckets", async () => { + const events: unknown[] = []; + const gateway = mockGateway( + makeUsage({ + burstPercent: 55, + sustainedPercent: 80, + billingPeriodEnd: "2026-06-01T00:00:00.000Z", + }), + ); + service = new UsageMonitorService(gateway, makeAgentService()); + service.on(UsageMonitorEvent.ThresholdCrossed, (e) => events.push(e)); + + await service.fetchOnce(); + expect(events).toHaveLength(2); + expect(events.map((e) => (e as { bucket: string }).bucket).sort()).toEqual([ + "burst", + "sustained", + ]); + }); + + it("marks events with isPro when billing_period_end is set", async () => { + const events: { isPro: boolean }[] = []; + const gateway = mockGateway( + makeUsage({ + sustainedPercent: 60, + billingPeriodEnd: "2026-06-01T00:00:00.000Z", + }), + ); + service = new UsageMonitorService(gateway, makeAgentService()); + service.on(UsageMonitorEvent.ThresholdCrossed, (e) => + events.push(e as { isPro: boolean }), + ); + + await service.fetchOnce(); + expect(events[0]?.isPro).toBe(true); + }); + + it("silently skips polls when the gateway throws", async () => { + const events: unknown[] = []; + const gateway = { + fetchUsage: vi.fn().mockRejectedValue(new Error("not authenticated")), + } as unknown as LlmGatewayService; + service = new UsageMonitorService(gateway, makeAgentService()); + service.on(UsageMonitorEvent.ThresholdCrossed, (e) => events.push(e)); + + await expect(service.fetchOnce()).resolves.toBeNull(); + expect(events).toHaveLength(0); + }); + + it("emits UsageUpdated only when the snapshot actually changes", async () => { + const updates: UsageOutput[] = []; + const gateway = { + fetchUsage: vi + .fn() + .mockResolvedValueOnce(makeUsage({ burstPercent: 20 })) + .mockResolvedValueOnce(makeUsage({ burstPercent: 20 })) + .mockResolvedValueOnce(makeUsage({ burstPercent: 35 })), + } as unknown as LlmGatewayService; + service = new UsageMonitorService(gateway, makeAgentService()); + service.on(UsageMonitorEvent.UsageUpdated, (u) => updates.push(u)); + + expect(service.getLatest()).toBeNull(); + await service.fetchOnce(); + expect(updates).toHaveLength(1); + expect(service.getLatest()?.burst.used_percent).toBe(20); + + // Identical snapshot — no re-emit. + await service.fetchOnce(); + expect(updates).toHaveLength(1); + + // Genuine change — re-emits. + await service.fetchOnce(); + expect(updates).toHaveLength(2); + expect(updates[1].burst.used_percent).toBe(35); + }); + + it("does not emit UsageUpdated when the gateway throws", async () => { + const updates: UsageOutput[] = []; + const gateway = { + fetchUsage: vi.fn().mockRejectedValue(new Error("offline")), + } as unknown as LlmGatewayService; + service = new UsageMonitorService(gateway, makeAgentService()); + service.on(UsageMonitorEvent.UsageUpdated, (u) => updates.push(u)); + + await service.fetchOnce(); + expect(updates).toHaveLength(0); + expect(service.getLatest()).toBeNull(); + }); + + it("refreshNow triggers a fresh fetch and returns the snapshot", async () => { + const gateway = mockGateway(makeUsage({ burstPercent: 42 })); + service = new UsageMonitorService(gateway, makeAgentService()); + + const result = await service.refreshNow(); + expect(result?.burst.used_percent).toBe(42); + expect(service.getLatest()?.burst.used_percent).toBe(42); + }); + + it("collapses bursts of LlmActivity into at most one trailing fetch", async () => { + const gateway = mockGateway(makeUsage({ burstPercent: 10 })); + const agent = makeAgentService(); + service = new UsageMonitorService(gateway, agent); + service.init(); + // Drain bootstrap (a microtask, not a timer). + await vi.advanceTimersByTimeAsync(0); + expect(gateway.fetchUsage).toHaveBeenCalledTimes(1); + + // Burst of 4 parallel agents finishing within the coalesce window. + agent.emit(AgentServiceEvent.LlmActivity, undefined); + agent.emit(AgentServiceEvent.LlmActivity, undefined); + agent.emit(AgentServiceEvent.LlmActivity, undefined); + agent.emit(AgentServiceEvent.LlmActivity, undefined); + // Nothing fires immediately — the coalesce window holds. + await vi.advanceTimersByTimeAsync(0); + expect(gateway.fetchUsage).toHaveBeenCalledTimes(1); + + // Advance past the coalesce window — exactly one trailing fetch fires. + await vi.advanceTimersByTimeAsync(5_000); + expect(gateway.fetchUsage).toHaveBeenCalledTimes(2); + + // A long gap, then a single event — fires once after the window. + await vi.advanceTimersByTimeAsync(60_000); + agent.emit(AgentServiceEvent.LlmActivity, undefined); + await vi.advanceTimersByTimeAsync(5_000); + expect(gateway.fetchUsage).toHaveBeenCalledTimes(3); + }); + + it("unsubscribes from agent events on stop()", async () => { + const gateway = mockGateway(makeUsage({ burstPercent: 10 })); + const agent = makeAgentService(); + service = new UsageMonitorService(gateway, agent); + service.init(); + await vi.advanceTimersByTimeAsync(0); + const baseline = (gateway.fetchUsage as ReturnType).mock.calls + .length; + + service.stop(); + agent.emit(AgentServiceEvent.LlmActivity, undefined); + await vi.advanceTimersByTimeAsync(10_000); + expect(gateway.fetchUsage).toHaveBeenCalledTimes(baseline); + }); +}); diff --git a/apps/code/src/main/services/usage-monitor/service.ts b/apps/code/src/main/services/usage-monitor/service.ts new file mode 100644 index 0000000000..281a547910 --- /dev/null +++ b/apps/code/src/main/services/usage-monitor/service.ts @@ -0,0 +1,287 @@ +import { inject, injectable, postConstruct, preDestroy } from "inversify"; +import { MAIN_TOKENS } from "../../di/tokens"; +import { logger } from "../../utils/logger"; +import { TypedEventEmitter } from "../../utils/typed-event-emitter"; +import { AgentServiceEvent } from "../agent/schemas"; +import type { AgentService } from "../agent/service"; +import type { UsageBucket, UsageOutput } from "../llm-gateway/schemas"; +import type { LlmGatewayService } from "../llm-gateway/service"; +import { + USAGE_THRESHOLDS, + UsageMonitorEvent, + type UsageMonitorEvents, + type UsageThreshold, +} from "./schemas"; +import { usageMonitorStore } from "./store"; + +const log = logger.scope("usage-monitor"); + +// Coalesce bursts (e.g. 4 parallel agents finishing turns) into one trailing +// fetch per window. +const COALESCE_INTERVAL_MS = 5_000; + +// Safety net for billing-period rollovers while the app sits idle and no +// LlmActivity events fire. +const BACKSTOP_INTERVAL_MS = 30 * 60_000; + +type BucketName = "burst" | "sustained"; + +@injectable() +export class UsageMonitorService extends TypedEventEmitter { + private backstopTimeoutId: ReturnType | null = null; + private coalesceTimeoutId: ReturnType | null = null; + private lastFetchStartedAt = 0; + private isFetching = false; + // Snapshot of the most recent thresholdsSeen map so we hit electron-store + // only when we actually persist a new threshold. + private thresholdsSeen: Record; + private latestUsage: UsageOutput | null = null; + + private readonly onLlmActivity = (): void => this.requestRefresh(); + + constructor( + @inject(MAIN_TOKENS.LlmGatewayService) + private readonly llmGateway: LlmGatewayService, + @inject(MAIN_TOKENS.AgentService) + private readonly agentService: AgentService, + ) { + super(); + this.thresholdsSeen = { ...usageMonitorStore.get("thresholdsSeen", {}) }; + } + + /** Last successful usage snapshot; null until the first fetch succeeds. */ + getLatest(): UsageOutput | null { + return this.latestUsage; + } + + /** Trigger an immediate refresh, returning the resulting snapshot. */ + async refreshNow(): Promise { + return this.fetchOnce(); + } + + /** + * Request a refresh in response to agent activity (turn-complete events). + * Coalesces bursts so N parallel agents finishing in quick succession + * produce at most two fetches (leading + trailing) per `COALESCE_INTERVAL_MS` + * window. Safe to call from many call sites with no rate-limit awareness. + */ + requestRefresh(): void { + if (this.coalesceTimeoutId) return; + const now = Date.now(); + const delay = Math.max( + 0, + this.lastFetchStartedAt + COALESCE_INTERVAL_MS - now, + ); + this.coalesceTimeoutId = setTimeout(() => { + this.coalesceTimeoutId = null; + void this.fetchOnce(); + }, delay); + } + + @postConstruct() + init(): void { + this.pruneStaleEntries(); + this.agentService.on(AgentServiceEvent.LlmActivity, this.onLlmActivity); + // Bootstrap so the UI doesn't show null until the first agent turn. + void this.fetchOnce(); + this.scheduleBackstop(); + } + + @preDestroy() + stop(): void { + this.agentService.off(AgentServiceEvent.LlmActivity, this.onLlmActivity); + if (this.backstopTimeoutId) { + clearTimeout(this.backstopTimeoutId); + this.backstopTimeoutId = null; + } + if (this.coalesceTimeoutId) { + clearTimeout(this.coalesceTimeoutId); + this.coalesceTimeoutId = null; + } + } + + async fetchOnce(): Promise { + if (this.isFetching) return null; + this.isFetching = true; + this.lastFetchStartedAt = Date.now(); + // Any pending coalesced fetch is satisfied by this one — drop it so the + // backstop and refreshNow() paths don't trigger a redundant follow-up. + if (this.coalesceTimeoutId) { + clearTimeout(this.coalesceTimeoutId); + this.coalesceTimeoutId = null; + } + try { + const usage = await this.fetchUsageQuietly(); + if (usage) { + const changed = !isSameUsage(this.latestUsage, usage); + this.latestUsage = usage; + if (changed) { + this.emit(UsageMonitorEvent.UsageUpdated, usage); + } + this.processUsage(usage); + } + return usage; + } finally { + this.isFetching = false; + } + } + + private async fetchUsageQuietly(): Promise { + try { + return await this.llmGateway.fetchUsage(); + } catch (err) { + log.debug("Usage fetch skipped", { + error: err instanceof Error ? err.message : String(err), + }); + return null; + } + } + + private scheduleBackstop(): void { + this.backstopTimeoutId = setTimeout(async () => { + this.backstopTimeoutId = null; + await this.fetchOnce(); + this.scheduleBackstop(); + }, BACKSTOP_INTERVAL_MS); + } + + private processUsage(usage: UsageOutput): void { + const userId = usage.user_id.toString(); + const product = usage.product; + // Plan-key isn't on UsageOutput; the only signal we have client-side is + // whether limits are at the Pro tier — but fetchUsage doesn't return that + // either. Best-effort: assume Pro if billing_period_end is present + // (free users never have it). + const isPro = !!usage.billing_period_end; + + this.maybeEmit(usage, "burst", usage.burst, userId, product, isPro); + this.maybeEmit(usage, "sustained", usage.sustained, userId, product, isPro); + } + + private maybeEmit( + usage: UsageOutput, + bucket: BucketName, + status: UsageBucket, + userId: string, + product: string, + isPro: boolean, + ): void { + const anchor = this.anchorFor(bucket, status, usage); + if (!anchor) return; + + const threshold = highestThresholdCrossed(status.used_percent); + if (threshold === null) return; + + const key = makeKey(userId, product, bucket, anchor, threshold); + if (this.thresholdsSeen[key]) return; + + this.thresholdsSeen[key] = anchor; + usageMonitorStore.set("thresholdsSeen", this.thresholdsSeen); + + log.info("Usage threshold crossed", { + bucket, + threshold, + usedPercent: status.used_percent, + }); + + this.emit(UsageMonitorEvent.ThresholdCrossed, { + bucket, + threshold, + usedPercent: status.used_percent, + resetAt: status.reset_at ?? null, + resetsInSeconds: status.resets_in_seconds, + isPro, + }); + } + + // Burst anchor rounds reset_at to the hour so transient TTL jitter doesn't + // make every poll look like a new window. Sustained anchor is the billing + // period end (Pro) or the reset_at ISO date (free). + private anchorFor( + bucket: BucketName, + status: UsageBucket, + usage: UsageOutput, + ): string | null { + if (bucket === "sustained") { + return usage.billing_period_end ?? sustainedFreeAnchor(status) ?? null; + } + return burstAnchor(status); + } + + private pruneStaleEntries(): void { + const now = Date.now(); + let dirty = false; + for (const [key, anchor] of Object.entries(this.thresholdsSeen)) { + const parsed = Date.parse(anchor); + if (Number.isNaN(parsed) || parsed < now) { + delete this.thresholdsSeen[key]; + dirty = true; + } + } + if (dirty) { + usageMonitorStore.set("thresholdsSeen", this.thresholdsSeen); + } + } +} + +function highestThresholdCrossed(usedPercent: number): UsageThreshold | null { + for (let i = USAGE_THRESHOLDS.length - 1; i >= 0; i--) { + const t = USAGE_THRESHOLDS[i]; + if (usedPercent >= t) return t; + } + return null; +} + +function burstAnchor(status: UsageBucket): string | null { + const resetMs = resetMillis(status); + if (resetMs === null) return null; + // Round to the nearest hour so 30s polling doesn't churn the anchor. + const rounded = Math.round(resetMs / 3_600_000) * 3_600_000; + return new Date(rounded).toISOString(); +} + +function sustainedFreeAnchor(status: UsageBucket): string | null { + const resetMs = resetMillis(status); + if (resetMs === null) return null; + return new Date(resetMs).toISOString().slice(0, 10); +} + +function resetMillis(status: UsageBucket): number | null { + if (status.reset_at) { + const parsed = Date.parse(status.reset_at); + if (!Number.isNaN(parsed)) return parsed; + } + if (status.resets_in_seconds > 0) { + return Date.now() + status.resets_in_seconds * 1000; + } + return null; +} + +function makeKey( + userId: string, + product: string, + bucket: BucketName, + anchor: string, + threshold: UsageThreshold, +): string { + return `${userId}:${product}:${bucket}:${anchor}:${threshold}`; +} + +function isSameUsage(a: UsageOutput | null, b: UsageOutput): boolean { + if (!a) return false; + return ( + a.is_rate_limited === b.is_rate_limited && + a.billing_period_end === b.billing_period_end && + isSameBucket(a.burst, b.burst) && + isSameBucket(a.sustained, b.sustained) + ); +} + +function isSameBucket(a: UsageBucket, b: UsageBucket): boolean { + return ( + a.used_percent === b.used_percent && + a.resets_in_seconds === b.resets_in_seconds && + a.reset_at === b.reset_at && + a.exceeded === b.exceeded + ); +} diff --git a/apps/code/src/main/services/usage-monitor/store.ts b/apps/code/src/main/services/usage-monitor/store.ts new file mode 100644 index 0000000000..95cc9a486b --- /dev/null +++ b/apps/code/src/main/services/usage-monitor/store.ts @@ -0,0 +1,17 @@ +import Store from "electron-store"; +import { getUserDataDir } from "../../utils/env"; + +interface UsageMonitorSchema { + // Map of dedupe-keys ⇒ ISO timestamp anchor at which the threshold was + // first fired. Stored so we don't re-toast after relaunch within the same + // billing window. Anchored entries with a past anchor are pruned on boot. + thresholdsSeen: Record; +} + +export const usageMonitorStore = new Store({ + name: "usage-monitor", + cwd: getUserDataDir(), + defaults: { + thresholdsSeen: {}, + }, +}); diff --git a/apps/code/src/main/trpc/router.ts b/apps/code/src/main/trpc/router.ts index 81fd00d4e3..f0f8dd9eb5 100644 --- a/apps/code/src/main/trpc/router.ts +++ b/apps/code/src/main/trpc/router.ts @@ -36,6 +36,7 @@ import { sleepRouter } from "./routers/sleep"; import { suspensionRouter } from "./routers/suspension.js"; import { uiRouter } from "./routers/ui"; import { updatesRouter } from "./routers/updates"; +import { usageMonitorRouter } from "./routers/usage-monitor"; import { workspaceRouter } from "./routers/workspace"; import { router } from "./trpc"; @@ -78,6 +79,7 @@ export const trpcRouter = router({ slackIntegration: slackIntegrationRouter, ui: uiRouter, updates: updatesRouter, + usageMonitor: usageMonitorRouter, deepLink: deepLinkRouter, workspace: workspaceRouter, }); diff --git a/apps/code/src/main/trpc/routers/llm-gateway.ts b/apps/code/src/main/trpc/routers/llm-gateway.ts index fe9862e1a3..2c0017dde4 100644 --- a/apps/code/src/main/trpc/routers/llm-gateway.ts +++ b/apps/code/src/main/trpc/routers/llm-gateway.ts @@ -1,10 +1,6 @@ import { container } from "../../di/container"; import { MAIN_TOKENS } from "../../di/tokens"; -import { - promptInput, - promptOutput, - usageOutput, -} from "../../services/llm-gateway/schemas"; +import { promptInput, promptOutput } from "../../services/llm-gateway/schemas"; import type { LlmGatewayService } from "../../services/llm-gateway/service"; import { publicProcedure, router } from "../trpc"; @@ -23,10 +19,6 @@ export const llmGatewayRouter = router({ }), ), - usage: publicProcedure - .output(usageOutput) - .query(() => getService().fetchUsage()), - invalidatePlanCache: publicProcedure.mutation(() => getService().invalidatePlanCache(), ), diff --git a/apps/code/src/main/trpc/routers/usage-monitor.ts b/apps/code/src/main/trpc/routers/usage-monitor.ts new file mode 100644 index 0000000000..ef3c63ab9e --- /dev/null +++ b/apps/code/src/main/trpc/routers/usage-monitor.ts @@ -0,0 +1,36 @@ +import { container } from "../../di/container"; +import { MAIN_TOKENS } from "../../di/tokens"; +import { + UsageMonitorEvent, + type UsageMonitorEvents, + usageSnapshotOutput, +} from "../../services/usage-monitor/schemas"; +import type { UsageMonitorService } from "../../services/usage-monitor/service"; +import { publicProcedure, router } from "../trpc"; + +const getService = () => + container.get(MAIN_TOKENS.UsageMonitorService); + +function subscribe(event: K) { + return publicProcedure.subscription(async function* (opts) { + const service = getService(); + const iterable = service.toIterable(event, { signal: opts.signal }); + for await (const data of iterable) { + yield data; + } + }); +} + +export const usageMonitorRouter = router({ + onThresholdCrossed: subscribe(UsageMonitorEvent.ThresholdCrossed), + // Stream of full usage snapshots — replaces the renderer's 30s poll. + onUsageUpdated: subscribe(UsageMonitorEvent.UsageUpdated), + // Cached snapshot for the renderer to bootstrap before the first event + // arrives. Null until the first poll completes. + getLatest: publicProcedure + .output(usageSnapshotOutput) + .query(() => getService().getLatest()), + refresh: publicProcedure + .output(usageSnapshotOutput) + .mutation(() => getService().refreshNow()), +}); diff --git a/apps/code/src/renderer/App.tsx b/apps/code/src/renderer/App.tsx index c6595e9e27..05de56b05a 100644 --- a/apps/code/src/renderer/App.tsx +++ b/apps/code/src/renderer/App.tsx @@ -12,6 +12,7 @@ import { } from "@features/auth/hooks/authQueries"; import { useAuthSession } from "@features/auth/hooks/useAuthSession"; import { useIsOrgAdmin } from "@features/auth/hooks/useOrgRole"; +import { initializeUsageThresholdToast } from "@features/billing/usageThresholdToast"; import { AddDirectoryDialog } from "@features/folder-picker/components/AddDirectoryDialog"; import { OnboardingFlow } from "@features/onboarding/components/OnboardingFlow"; import { useOnboardingStore } from "@features/onboarding/stores/onboardingStore"; @@ -63,6 +64,12 @@ function App() { }; }, []); + // Initialize usage threshold notifications (50/75/90/100%) + useEffect(() => { + if (!isAuthenticated) return; + return initializeUsageThresholdToast(); + }, [isAuthenticated]); + // Initialize update store useEffect(() => { return initializeUpdateStore(); diff --git a/apps/code/src/renderer/components/MainLayout.tsx b/apps/code/src/renderer/components/MainLayout.tsx index d4fb8bc328..ff1d04eecf 100644 --- a/apps/code/src/renderer/components/MainLayout.tsx +++ b/apps/code/src/renderer/components/MainLayout.tsx @@ -5,7 +5,6 @@ import { SpaceSwitcher } from "@components/SpaceSwitcher"; import { ArchivedTasksView } from "@features/archive/components/ArchivedTasksView"; import { UsageLimitModal } from "@features/billing/components/UsageLimitModal"; -import { useUsageLimitDetection } from "@features/billing/hooks/useUsageLimitDetection"; import { CommandMenu } from "@features/command/components/CommandMenu"; import { CommandCenterView } from "@features/command-center/components/CommandCenterView"; import { InboxView } from "@features/inbox/components/InboxView"; @@ -77,7 +76,6 @@ export function MainLayout() { const activeTaskId = view.type === "task-detail" && view.data ? view.data.id : null; - useUsageLimitDetection(billingEnabled); useIntegrations(); useTaskDeepLink(); useInboxDeepLink(); diff --git a/apps/code/src/renderer/features/billing/components/SidebarUsageBar.tsx b/apps/code/src/renderer/features/billing/components/SidebarUsageBar.tsx index 6211b5dcf5..c18b21d12a 100644 --- a/apps/code/src/renderer/features/billing/components/SidebarUsageBar.tsx +++ b/apps/code/src/renderer/features/billing/components/SidebarUsageBar.tsx @@ -1,5 +1,5 @@ import { useFreeUsage } from "@features/billing/hooks/useFreeUsage"; -import { isUsageExceeded } from "@features/billing/utils"; +import { formatResetTime, isUsageExceeded } from "@features/billing/utils"; import { useSettingsDialogStore } from "@features/settings/stores/settingsDialogStore"; import { useFeatureFlag } from "@hooks/useFeatureFlag"; import { Circle } from "@phosphor-icons/react"; @@ -7,20 +7,37 @@ import { BILLING_FLAG } from "@shared/constants"; export function SidebarUsageBar() { const billingEnabled = useFeatureFlag(BILLING_FLAG); - const usage = useFreeUsage(billingEnabled); + const { usage, isLoading } = useFreeUsage(billingEnabled); - if (!usage) return null; - - const usagePercent = Math.max( - usage.sustained.used_percent, - usage.burst.used_percent, - ); - const exceeded = isUsageExceeded(usage); + if (!billingEnabled) return null; const handleUpgrade = () => { useSettingsDialogStore.getState().open("plan-usage"); }; + if (!usage) { + if (!isLoading) return null; + return ( +
+
+ Free plan +
+
+
+ ); + } + + const exceeded = isUsageExceeded(usage); + const dominant = + usage.sustained.used_percent >= usage.burst.used_percent + ? usage.sustained + : usage.burst; + const usagePercent = Math.min(Math.round(dominant.used_percent), 100); + const resetLabel = formatResetTime( + dominant.reset_at, + dominant.resets_in_seconds, + ); + return (
@@ -32,9 +49,7 @@ export function SidebarUsageBar() { className="mx-1.5 inline text-gray-8" /> - {exceeded - ? "Limit reached" - : `${Math.min(Math.round(usagePercent), 100)}% used`} + {exceeded ? "Limit reached" : `${usagePercent}% used`}
); } diff --git a/apps/code/src/renderer/features/billing/hooks/useFreeUsage.ts b/apps/code/src/renderer/features/billing/hooks/useFreeUsage.ts index fb20fa6f1e..bfcf56a802 100644 --- a/apps/code/src/renderer/features/billing/hooks/useFreeUsage.ts +++ b/apps/code/src/renderer/features/billing/hooks/useFreeUsage.ts @@ -2,13 +2,19 @@ import { useSeat } from "@hooks/useSeat"; import type { UsageOutput } from "@main/services/llm-gateway/schemas"; import { useUsage } from "./useUsage"; -export function useFreeUsage(billingEnabled: boolean): UsageOutput | null { +export interface FreeUsageResult { + usage: UsageOutput | null; + // True when the user is eligible to see the Free sidebar bar but data + // hasn't arrived yet. Distinguishes "show skeleton" from "render nothing". + isLoading: boolean; +} + +export function useFreeUsage(billingEnabled: boolean): FreeUsageResult { const { seat, isPro } = useSeat(); const seatLoaded = seat !== null; - const { usage } = useUsage({ - enabled: billingEnabled && seatLoaded && !isPro, - }); + const eligible = billingEnabled && seatLoaded && !isPro; + const { usage, isLoading } = useUsage({ enabled: eligible }); - if (!billingEnabled || !seatLoaded || isPro || !usage) return null; - return usage; + if (!eligible) return { usage: null, isLoading: false }; + return { usage: usage ?? null, isLoading }; } diff --git a/apps/code/src/renderer/features/billing/hooks/useUsage.ts b/apps/code/src/renderer/features/billing/hooks/useUsage.ts index a48e426afe..3eee75bca6 100644 --- a/apps/code/src/renderer/features/billing/hooks/useUsage.ts +++ b/apps/code/src/renderer/features/billing/hooks/useUsage.ts @@ -1,21 +1,44 @@ import { useTRPC } from "@renderer/trpc"; -import { useRendererWindowFocusStore } from "@stores/rendererWindowFocusStore"; -import { useQuery } from "@tanstack/react-query"; - -const USAGE_REFETCH_INTERVAL_MS = 30_000; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { useSubscription } from "@trpc/tanstack-react-query"; +import { useCallback } from "react"; +/** + * Subscribe to usage snapshots pushed by the main-process `UsageMonitorService`. + * The service is the single source of truth — it pushes fresh snapshots after + * every agent turn (via the LlmActivity event) plus a 30-minute backstop. + */ export function useUsage({ enabled = true }: { enabled?: boolean } = {}) { const trpc = useTRPC(); - const focused = useRendererWindowFocusStore((s) => s.focused); - const { - data: usage, - isLoading, - refetch, - } = useQuery({ - ...trpc.llmGateway.usage.queryOptions(), + const queryClient = useQueryClient(); + const query = useQuery({ + ...trpc.usageMonitor.getLatest.queryOptions(), enabled, - refetchInterval: focused && enabled ? USAGE_REFETCH_INTERVAL_MS : false, - refetchIntervalInBackground: false, }); - return { usage: usage ?? null, isLoading, refetch }; + const refreshMutation = useMutation( + trpc.usageMonitor.refresh.mutationOptions(), + ); + + useSubscription( + trpc.usageMonitor.onUsageUpdated.subscriptionOptions(undefined, { + enabled, + onData: (data) => { + queryClient.setQueryData(trpc.usageMonitor.getLatest.queryKey(), data); + }, + }), + ); + + const refetch = useCallback(async () => { + const fresh = await refreshMutation.mutateAsync(); + if (fresh) { + queryClient.setQueryData(trpc.usageMonitor.getLatest.queryKey(), fresh); + } + return fresh; + }, [refreshMutation, queryClient, trpc.usageMonitor.getLatest]); + + return { + usage: query.data ?? null, + isLoading: query.isLoading, + refetch, + }; } diff --git a/apps/code/src/renderer/features/billing/hooks/useUsageLimitDetection.ts b/apps/code/src/renderer/features/billing/hooks/useUsageLimitDetection.ts deleted file mode 100644 index ac08dae2d8..0000000000 --- a/apps/code/src/renderer/features/billing/hooks/useUsageLimitDetection.ts +++ /dev/null @@ -1,38 +0,0 @@ -import { useUsageLimitStore } from "@features/billing/stores/usageLimitStore"; -import { isUsageExceeded } from "@features/billing/utils"; -import { useSessionStore } from "@features/sessions/stores/sessionStore"; -import { useEffect, useRef } from "react"; -import { useFreeUsage } from "./useFreeUsage"; - -export function useUsageLimitDetection(billingEnabled: boolean) { - const usage = useFreeUsage(billingEnabled); - const hasAlertedRef = useRef(false); - - useEffect(() => { - if (!billingEnabled) { - hasAlertedRef.current = false; - } - }, [billingEnabled]); - - useEffect(() => { - if (!usage) return; - - const exceeded = isUsageExceeded(usage); - - if (exceeded && !hasAlertedRef.current) { - const sessions = useSessionStore.getState().sessions; - const hasActiveSession = Object.values(sessions).some( - (s) => s.status === "connected" && s.isPromptPending, - ); - - if (hasActiveSession) { - hasAlertedRef.current = true; - useUsageLimitStore.getState().show(); - } - } - - if (!exceeded) { - hasAlertedRef.current = false; - } - }, [usage]); -} diff --git a/apps/code/src/renderer/features/billing/usageThresholdToast.ts b/apps/code/src/renderer/features/billing/usageThresholdToast.ts new file mode 100644 index 0000000000..d6b3a005cb --- /dev/null +++ b/apps/code/src/renderer/features/billing/usageThresholdToast.ts @@ -0,0 +1,65 @@ +import { useUsageLimitStore } from "@features/billing/stores/usageLimitStore"; +import { formatResetTime } from "@features/billing/utils"; +import { useSessionStore } from "@features/sessions/stores/sessionStore"; +import { useSettingsDialogStore } from "@features/settings/stores/settingsDialogStore"; +import { trpcClient } from "@renderer/trpc/client"; +import { logger } from "@utils/logger"; +import { toast } from "@utils/toast"; + +const log = logger.scope("usage-threshold-toast"); + +const openPlanUsage = () => { + useSettingsDialogStore.getState().open("plan-usage"); +}; + +function hasActiveSession(): boolean { + const sessions = useSessionStore.getState().sessions; + return Object.values(sessions).some( + (s) => s.status === "connected" && s.isPromptPending, + ); +} + +export function initializeUsageThresholdToast() { + const subscription = trpcClient.usageMonitor.onThresholdCrossed.subscribe( + undefined, + { + onData: (event) => { + const resetLabel = formatResetTime( + event.resetAt ?? undefined, + event.resetsInSeconds, + ); + + if (event.threshold === 100) { + if (hasActiveSession()) { + useUsageLimitStore.getState().show(); + return; + } + toast.error("Usage limit reached", { + id: `usage-threshold-${event.bucket}-100`, + description: resetLabel, + }); + return; + } + + const limitName = + event.bucket === "burst" ? "daily limit" : "monthly limit"; + toast.warning( + `You've used ${Math.round(event.usedPercent)}% of your ${limitName}`, + { + id: `usage-threshold-${event.bucket}-${event.threshold}`, + description: resetLabel, + action: { label: "View usage", onClick: openPlanUsage }, + duration: 10_000, + }, + ); + }, + onError: (error) => { + log.error("Usage threshold subscription error", { error }); + }, + }, + ); + + return () => { + subscription.unsubscribe(); + }; +} diff --git a/apps/code/src/renderer/features/billing/utils.test.ts b/apps/code/src/renderer/features/billing/utils.test.ts index 8e8db3c3f8..3a6f6891d1 100644 --- a/apps/code/src/renderer/features/billing/utils.test.ts +++ b/apps/code/src/renderer/features/billing/utils.test.ts @@ -1,6 +1,6 @@ import type { UsageOutput } from "@main/services/llm-gateway/schemas"; import { describe, expect, it } from "vitest"; -import { isUsageExceeded } from "./utils"; +import { formatResetTime, isUsageExceeded } from "./utils"; function makeUsage( overrides: Partial<{ @@ -51,3 +51,36 @@ describe("isUsageExceeded", () => { ).toBe(true); }); }); + +describe("formatResetTime", () => { + const NOW = Date.parse("2026-05-01T12:00:00.000Z"); + + it("returns minutes-only under 1h", () => { + expect(formatResetTime(undefined, 30 * 60, NOW)).toBe("Resets in 30m"); + }); + + it("returns hours + minutes under 24h", () => { + expect(formatResetTime(undefined, 4 * 3600 + 30 * 60, NOW)).toBe( + "Resets in 4h 30m", + ); + }); + + it("returns hours only when minutes round to 0", () => { + expect(formatResetTime(undefined, 4 * 3600, NOW)).toBe("Resets in 4h"); + }); + + it("returns localized date when over 24h away", () => { + const result = formatResetTime(undefined, 30 * 86400, NOW); + expect(result).toMatch(/^Resets [A-Za-z]+ \d+ at /); + }); + + it("prefers reset_at over the fallback seconds", () => { + const iso = new Date(NOW + 4 * 3600 * 1000).toISOString(); + expect(formatResetTime(iso, 99999, NOW)).toBe("Resets in 4h"); + }); + + it("treats an already-past reset_at as shortly", () => { + const iso = new Date(NOW - 60_000).toISOString(); + expect(formatResetTime(iso, 0, NOW)).toBe("Resets shortly"); + }); +}); diff --git a/apps/code/src/renderer/features/billing/utils.ts b/apps/code/src/renderer/features/billing/utils.ts index f0ad86830d..10117b10d3 100644 --- a/apps/code/src/renderer/features/billing/utils.ts +++ b/apps/code/src/renderer/features/billing/utils.ts @@ -5,3 +5,38 @@ export function isUsageExceeded(usage: UsageOutput): boolean { usage.is_rate_limited || usage.sustained.exceeded || usage.burst.exceeded ); } + +export function formatResetTime( + resetAtIso: string | undefined, + fallbackSeconds: number, + now: number = Date.now(), +): string { + const ms = resetAtIso + ? Math.max(0, Date.parse(resetAtIso) - now) + : Math.max(0, fallbackSeconds * 1000); + + const totalMinutes = Math.ceil(ms / 60_000); + if (totalMinutes <= 0) return "Resets shortly"; + if (totalMinutes < 60) return `Resets in ${totalMinutes}m`; + + const totalHours = ms / 3_600_000; + if (totalHours < 24) { + const hours = Math.floor(totalHours); + const minutes = Math.round((totalHours - hours) * 60); + return minutes === 0 + ? `Resets in ${hours}h` + : `Resets in ${hours}h ${minutes}m`; + } + + const target = new Date(now + ms); + const date = target.toLocaleDateString(undefined, { + month: "short", + day: "numeric", + }); + const time = target.toLocaleTimeString(undefined, { + hour: "numeric", + minute: "2-digit", + timeZoneName: "short", + }); + return `Resets ${date} at ${time}`; +} diff --git a/apps/code/src/renderer/features/sessions/components/ContextBreakdownPopover.test.tsx b/apps/code/src/renderer/features/sessions/components/ContextBreakdownPopover.test.tsx new file mode 100644 index 0000000000..bc6c6b085a --- /dev/null +++ b/apps/code/src/renderer/features/sessions/components/ContextBreakdownPopover.test.tsx @@ -0,0 +1,65 @@ +import type { ContextUsage } from "@features/sessions/hooks/useContextUsage"; +import { Theme } from "@radix-ui/themes"; +import { render, screen } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; +import { ContextBreakdownPopover } from "./ContextBreakdownPopover"; + +function usageWith( + breakdown: ContextUsage["breakdown"], + overrides?: Partial, +): ContextUsage { + return { + used: 74_000, + size: 200_000, + percentage: 37, + cost: null, + breakdown, + ...overrides, + }; +} + +describe("ContextBreakdownPopover", () => { + it("renders the header with aggregate tokens", () => { + render( + + + , + ); + expect(screen.getByText(/74K \/ 200K tokens/)).toBeInTheDocument(); + expect(screen.getByText("37% full")).toBeInTheDocument(); + }); + + it("shows the placeholder copy when breakdown is missing", () => { + render( + + + , + ); + expect( + screen.getByText(/Detailed breakdown available after the first response/), + ).toBeInTheDocument(); + }); + + it("renders one row per non-zero category", () => { + render( + + + , + ); + expect(screen.getByText("System prompt")).toBeInTheDocument(); + expect(screen.getByText("MCP")).toBeInTheDocument(); + expect(screen.getByText("Conversation")).toBeInTheDocument(); + expect(screen.queryByText("Tools")).not.toBeInTheDocument(); + expect(screen.queryByText("Rules")).not.toBeInTheDocument(); + }); +}); diff --git a/apps/code/src/renderer/features/sessions/components/ContextBreakdownPopover.tsx b/apps/code/src/renderer/features/sessions/components/ContextBreakdownPopover.tsx new file mode 100644 index 0000000000..5ecbe90fda --- /dev/null +++ b/apps/code/src/renderer/features/sessions/components/ContextBreakdownPopover.tsx @@ -0,0 +1,117 @@ +import type { ContextUsage } from "@features/sessions/hooks/useContextUsage"; +import { + CONTEXT_CATEGORIES, + formatTokensCompact, + getOverallUsageColor, +} from "@features/sessions/utils/contextColors"; +import { Flex, Text } from "@radix-ui/themes"; + +interface ContextBreakdownPopoverProps { + usage: ContextUsage; +} + +export function ContextBreakdownPopover({ + usage, +}: ContextBreakdownPopoverProps) { + const { used, size, percentage, breakdown } = usage; + const fillColor = getOverallUsageColor(percentage); + + return ( + + + + Context + + + ~{formatTokensCompact(used)} / {formatTokensCompact(size)} tokens + + + + + {percentage}% full + + + {breakdown ? ( + + ) : ( + + )} + + {breakdown ? ( + + {CONTEXT_CATEGORIES.filter((c) => breakdown[c.key] > 0).map((cat) => ( + + + + {cat.label} + + + {formatTokensCompact(breakdown[cat.key])} + + + ))} + + ) : ( + + Detailed breakdown available after the first response. + + )} + + ); +} + +function SegmentedBar({ + breakdown, + total, + fallback, +}: { + breakdown: NonNullable; + total: number; + fallback: string; +}) { + if (total <= 0) { + return
; + } + return ( +
+ {CONTEXT_CATEGORIES.map((cat) => { + const value = breakdown[cat.key]; + if (value <= 0) return null; + return ( +
+ ); + })} +
+ ); +} + +function SinglePercentBar({ + percentage, + color, +}: { + percentage: number; + color: string; +}) { + return ( +
+
+
+ ); +} diff --git a/apps/code/src/renderer/features/sessions/components/ContextUsageIndicator.tsx b/apps/code/src/renderer/features/sessions/components/ContextUsageIndicator.tsx index 79b23ff686..f1ac3c11ba 100644 --- a/apps/code/src/renderer/features/sessions/components/ContextUsageIndicator.tsx +++ b/apps/code/src/renderer/features/sessions/components/ContextUsageIndicator.tsx @@ -1,24 +1,10 @@ -import { Tooltip } from "@components/ui/Tooltip"; import type { ContextUsage } from "@features/sessions/hooks/useContextUsage"; -import { Flex, Text } from "@radix-ui/themes"; - -function formatTokensCompact(tokens: number): string { - if (tokens >= 1_000_000) { - return `${(tokens / 1_000_000).toFixed(1)}M`; - } - return `${Math.round(tokens / 1000)}K`; -} - -function formatTokensFull(tokens: number): string { - return tokens.toLocaleString(); -} - -function getUsageColor(percentage: number): string { - if (percentage >= 90) return "var(--red-9)"; - if (percentage >= 75) return "var(--orange-9)"; - if (percentage >= 50) return "var(--amber-9)"; - return "var(--green-9)"; -} +import { + formatTokensCompact, + getOverallUsageColor, +} from "@features/sessions/utils/contextColors"; +import { Flex, Popover, Text } from "@radix-ui/themes"; +import { ContextBreakdownPopover } from "./ContextBreakdownPopover"; const CIRCLE_SIZE = 20; const STROKE_WIDTH = 2.5; @@ -34,45 +20,54 @@ export function ContextUsageIndicator({ usage }: ContextUsageIndicatorProps) { const { used, size, percentage } = usage; const strokeDashoffset = CIRCUMFERENCE - (percentage / 100) * CIRCUMFERENCE; - const color = getUsageColor(percentage); + const color = getOverallUsageColor(percentage); return ( - - - + + - - {formatTokensCompact(used)}/{formatTokensCompact(size)} - - - + + + + {formatTokensCompact(used)}/{formatTokensCompact(size)} ·{" "} + {percentage}% + + + + + + + + ); } diff --git a/apps/code/src/renderer/features/sessions/hooks/useContextUsage.test.ts b/apps/code/src/renderer/features/sessions/hooks/useContextUsage.test.ts new file mode 100644 index 0000000000..88c37ffa02 --- /dev/null +++ b/apps/code/src/renderer/features/sessions/hooks/useContextUsage.test.ts @@ -0,0 +1,79 @@ +import type { AcpMessage } from "@shared/types/session-events"; +import { describe, expect, it } from "vitest"; +import { extractContextUsage } from "./useContextUsage"; + +function usageUpdateEvent(used: number, size: number): AcpMessage { + return { + type: "acp_message", + ts: 1, + message: { + jsonrpc: "2.0", + method: "session/update", + params: { + sessionId: "s1", + update: { sessionUpdate: "usage_update", used, size }, + }, + }, + }; +} + +function breakdownEvent( + breakdown: Record, + method = "_posthog/usage_update", +): AcpMessage { + return { + type: "acp_message", + ts: 1, + message: { jsonrpc: "2.0", method, params: { sessionId: "s1", breakdown } }, + }; +} + +describe("extractContextUsage", () => { + it("returns null with no usage event", () => { + expect(extractContextUsage([])).toBeNull(); + }); + + it("derives aggregate from the latest session/update", () => { + const result = extractContextUsage([usageUpdateEvent(50_000, 200_000)]); + expect(result?.used).toBe(50_000); + expect(result?.size).toBe(200_000); + expect(result?.percentage).toBe(25); + expect(result?.breakdown).toBeNull(); + }); + + it("merges breakdown from a _posthog/usage_update notification", () => { + const result = extractContextUsage([ + usageUpdateEvent(50_000, 200_000), + breakdownEvent({ + systemPrompt: 4000, + tools: 500, + rules: 0, + skills: 0, + mcp: 0, + subagents: 0, + conversation: 45_500, + }), + ]); + expect(result?.breakdown?.systemPrompt).toBe(4000); + expect(result?.breakdown?.conversation).toBe(45_500); + }); + + it("tolerates the double-underscore method prefix from extNotification", () => { + const result = extractContextUsage([ + usageUpdateEvent(50_000, 200_000), + breakdownEvent( + { + systemPrompt: 4000, + tools: 0, + rules: 0, + skills: 0, + mcp: 0, + subagents: 0, + conversation: 46_000, + }, + "__posthog/usage_update", + ), + ]); + expect(result?.breakdown?.systemPrompt).toBe(4000); + }); +}); diff --git a/apps/code/src/renderer/features/sessions/hooks/useContextUsage.ts b/apps/code/src/renderer/features/sessions/hooks/useContextUsage.ts index b340e9abbc..8c1e2dcc88 100644 --- a/apps/code/src/renderer/features/sessions/hooks/useContextUsage.ts +++ b/apps/code/src/renderer/features/sessions/hooks/useContextUsage.ts @@ -1,11 +1,26 @@ import type { AcpMessage } from "@shared/types/session-events"; import { useMemo } from "react"; +// Shape mirrors `ContextBreakdown` emitted by the agent in +// `_posthog/usage_update` (see packages/agent/src/adapters/claude/context-breakdown.ts). +// Kept local to avoid a renderer dependency on the agent package; if the shape +// drifts, lift it into @posthog/shared. +export interface ContextBreakdown { + systemPrompt: number; + tools: number; + rules: number; + skills: number; + mcp: number; + subagents: number; + conversation: number; +} + export interface ContextUsage { used: number; size: number; percentage: number; cost: { amount: number; currency: string } | null; + breakdown: ContextBreakdown | null; } /** @@ -18,42 +33,75 @@ export function useContextUsage(events: AcpMessage[]): ContextUsage | null { } export function extractContextUsage(events: AcpMessage[]): ContextUsage | null { + let aggregate: Omit | null = null; + let breakdown: ContextBreakdown | null = null; + for (let i = events.length - 1; i >= 0; i--) { const msg = events[i].message; + if (!aggregate) { + aggregate = extractAggregate(msg); + } + if (!breakdown) { + breakdown = extractBreakdown(msg); + } + if (aggregate && breakdown) break; + } + + if (!aggregate) return null; + return { ...aggregate, breakdown }; +} + +function extractAggregate( + msg: AcpMessage["message"], +): Omit | null { + if ( + "method" in msg && + msg.method === "session/update" && + !("id" in msg) && + "params" in msg + ) { + const params = msg.params as + | { + update?: { + sessionUpdate?: string; + used?: number; + size?: number; + cost?: { amount: number; currency: string } | null; + }; + } + | undefined; + const update = params?.update; if ( - "method" in msg && - msg.method === "session/update" && - !("id" in msg) && - "params" in msg + update?.sessionUpdate === "usage_update" && + typeof update.used === "number" && + typeof update.size === "number" ) { - const params = msg.params as - | { - update?: { - sessionUpdate?: string; - used?: number; - size?: number; - cost?: { amount: number; currency: string } | null; - }; - } - | undefined; - const update = params?.update; - if ( - update?.sessionUpdate === "usage_update" && - typeof update.used === "number" && - typeof update.size === "number" - ) { - const percentage = - update.size > 0 - ? Math.min(100, Math.round((update.used / update.size) * 100)) - : 0; - return { - used: update.used, - size: update.size, - percentage, - cost: update.cost ?? null, - }; - } + const percentage = + update.size > 0 + ? Math.min(100, Math.round((update.used / update.size) * 100)) + : 0; + return { + used: update.used, + size: update.size, + percentage, + cost: update.cost ?? null, + }; } } return null; } + +function extractBreakdown(msg: AcpMessage["message"]): ContextBreakdown | null { + if (!("method" in msg) || !("params" in msg)) return null; + // Method may be received as either `_posthog/usage_update` or + // `__posthog/usage_update` depending on how the transport stringifies it + // (see acp-extensions.ts:matchesExt). + if ( + msg.method !== "_posthog/usage_update" && + msg.method !== "__posthog/usage_update" + ) { + return null; + } + const params = msg.params as { breakdown?: ContextBreakdown } | undefined; + return params?.breakdown ?? null; +} diff --git a/apps/code/src/renderer/features/sessions/utils/contextColors.ts b/apps/code/src/renderer/features/sessions/utils/contextColors.ts new file mode 100644 index 0000000000..befe85260f --- /dev/null +++ b/apps/code/src/renderer/features/sessions/utils/contextColors.ts @@ -0,0 +1,33 @@ +import type { ContextBreakdown } from "@features/sessions/hooks/useContextUsage"; + +export interface CategoryStyle { + key: keyof ContextBreakdown; + label: string; + color: string; +} + +// Ordered like the design spec: System prompt, Tools, Rules, Skills, MCP, +// Subagents, Conversation. Colors reuse Radix scales so they read in both +// light/dark modes. +export const CONTEXT_CATEGORIES: readonly CategoryStyle[] = [ + { key: "systemPrompt", label: "System prompt", color: "var(--gray-9)" }, + { key: "tools", label: "Tools", color: "var(--violet-9)" }, + { key: "rules", label: "Rules", color: "var(--green-9)" }, + { key: "skills", label: "Skills", color: "var(--amber-9)" }, + { key: "mcp", label: "MCP", color: "var(--pink-9)" }, + { key: "subagents", label: "Subagents", color: "var(--blue-9)" }, + { key: "conversation", label: "Conversation", color: "var(--orange-9)" }, +] as const; + +export function getOverallUsageColor(percentage: number): string { + if (percentage >= 90) return "var(--red-9)"; + if (percentage >= 75) return "var(--orange-9)"; + if (percentage >= 50) return "var(--amber-9)"; + return "var(--green-9)"; +} + +export function formatTokensCompact(tokens: number): string { + if (tokens >= 1_000_000) return `${(tokens / 1_000_000).toFixed(1)}M`; + if (tokens >= 1000) return `${Math.round(tokens / 1000)}K`; + return tokens.toString(); +} diff --git a/apps/code/src/renderer/features/settings/components/sections/PlanUsageSettings.tsx b/apps/code/src/renderer/features/settings/components/sections/PlanUsageSettings.tsx index ea5b9c7bdd..62cdc3dcc6 100644 --- a/apps/code/src/renderer/features/settings/components/sections/PlanUsageSettings.tsx +++ b/apps/code/src/renderer/features/settings/components/sections/PlanUsageSettings.tsx @@ -3,6 +3,7 @@ import { useAuthStateValue } from "@features/auth/hooks/authQueries"; import { TokenSpendAnalysisBanner } from "@features/billing/components/TokenSpendAnalysisBanner"; import { useUsage } from "@features/billing/hooks/useUsage"; import { useSeatStore } from "@features/billing/stores/seatStore"; +import { formatResetTime } from "@features/billing/utils"; import { useFeatureFlag } from "@hooks/useFeatureFlag"; import { useSeat } from "@hooks/useSeat"; import type { UsageBucket } from "@main/services/llm-gateway/schemas"; @@ -47,17 +48,6 @@ async function openBillingPage(orgId: string | null): Promise { if (url) window.open(url, "_blank"); } -function formatResetTime(seconds: number): string { - if (seconds < 3600) return "less than 1 hour"; - if (seconds < 86400) { - const hours = Math.ceil(seconds / 3600); - return hours === 1 ? "1 hour" : `${hours} hours`; - } - const days = Math.ceil(seconds / 86400); - if (days === 1) return "1 day"; - return `${days} days`; -} - export function PlanUsageSettings() { const { seat, @@ -452,7 +442,7 @@ function UsageMeter({ label, bucket, color }: UsageMeterProps) { {bucket.exceeded ? "Limit exceeded" - : `Resets in ${formatResetTime(bucket.resets_in_seconds)}`} + : formatResetTime(bucket.reset_at, bucket.resets_in_seconds)} ); diff --git a/apps/code/src/renderer/utils/toast.tsx b/apps/code/src/renderer/utils/toast.tsx index e1d610b580..611016d61d 100644 --- a/apps/code/src/renderer/utils/toast.tsx +++ b/apps/code/src/renderer/utils/toast.tsx @@ -149,7 +149,12 @@ export const toast = { warning: ( title: ReactNode, - options?: { description?: string; id?: string | number; duration?: number }, + options?: { + description?: string; + id?: string | number; + duration?: number; + action?: ToastAction; + }, ) => { return sonnerToast.custom( (id) => ( @@ -158,6 +163,7 @@ export const toast = { type="warning" title={title} description={options?.description} + action={options?.action} /> ), { id: options?.id, duration: options?.duration }, diff --git a/packages/agent/src/adapters/claude/claude-agent.ts b/packages/agent/src/adapters/claude/claude-agent.ts index cb712669c3..a307e5a5bf 100644 --- a/packages/agent/src/adapters/claude/claude-agent.ts +++ b/packages/agent/src/adapters/claude/claude-agent.ts @@ -68,6 +68,14 @@ import { Pushable } from "../../utils/streams"; import { BaseAcpAgent } from "../base-acp-agent"; import { LOCAL_TOOLS_MCP_NAME } from "../local-tools"; import { resolveTaskId } from "../session-meta"; +import { + buildBreakdown, + emptyBaseline, + estimateMcpTokens, + estimateRulesTokens, + estimateSkillsTokens, + estimateSystemPrompt, +} from "./context-breakdown"; import { promptToClaude } from "./conversion/acp-to-sdk"; import { handleResultMessage, @@ -79,6 +87,7 @@ import type { EnrichedReadCache } from "./hooks"; import { createLocalToolsMcpServer } from "./mcp/local-tools"; import { fetchMcpToolMetadata, + getCachedMcpTools, getConnectedMcpServerNames, setMcpToolApprovalStates, } from "./mcp/tool-metadata"; @@ -118,6 +127,23 @@ const SESSION_VALIDATION_TIMEOUT_MS = 30_000; const MAX_TITLE_LENGTH = 256; const LOCAL_ONLY_COMMANDS = new Set(["/context", "/heapdump", "/extra-usage"]); +/** Read CLAUDE.md from the project root so the context breakdown can size the + * Rules category. Best-effort: silent on a missing file, logs otherwise so + * permission errors aren't lost. */ +function readClaudeMdQuietly(cwd: string, logger: Logger): string | undefined { + try { + return fs.readFileSync(path.join(cwd, "CLAUDE.md"), "utf-8"); + } catch (err) { + if ((err as NodeJS.ErrnoException)?.code !== "ENOENT") { + logger.warn("Failed to read CLAUDE.md for context breakdown", { + cwd, + error: err instanceof Error ? err.message : String(err), + }); + } + return undefined; + } +} + function sanitizeTitle(text: string): string { const sanitized = text .replace(/[\r\n]+/g, " ") @@ -556,6 +582,16 @@ export class ClaudeAcpAgent extends BaseAcpAgent { }); } + // Use the latest outermost-model snapshot, not `message.usage`. + // The SDK's result usage is cumulative across every round of the + // agentic loop (each tool-use iteration re-counts the prompt), so + // it overstates the resident context several-fold. `lastStreamUsage` + // tracks the most recent `message_start`/`message_delta` for the + // outermost model, which is what's actually sitting in the window. + const breakdownInputTokens = + lastStreamUsage.input_tokens + + lastStreamUsage.cache_read_input_tokens + + lastStreamUsage.cache_creation_input_tokens; await this.client.extNotification( POSTHOG_NOTIFICATIONS.USAGE_UPDATE, { @@ -567,6 +603,10 @@ export class ClaudeAcpAgent extends BaseAcpAgent { cachedWriteTokens: message.usage.cache_creation_input_tokens, }, cost: message.total_cost_usd, + breakdown: buildBreakdown( + this.session.contextBreakdownBaseline ?? emptyBaseline(), + breakdownInputTokens, + ), }, ); @@ -1221,6 +1261,11 @@ export class ClaudeAcpAgent extends BaseAcpAgent { pendingMessages: new Map(), nextPendingOrder: 0, emitRawSDKMessages: meta?.claudeCode?.emitRawSDKMessages ?? false, + contextBreakdownBaseline: { + ...emptyBaseline(), + systemPrompt: estimateSystemPrompt(systemPrompt), + rules: estimateRulesTokens(readClaudeMdQuietly(cwd, this.logger)), + }, // Custom properties cwd, @@ -1547,13 +1592,30 @@ export class ClaudeAcpAgent extends BaseAcpAgent { private async sendAvailableCommandsUpdate(): Promise { const commands = await this.session.query.supportedCommands(); + const available = getAvailableSlashCommands(commands); await this.client.sessionUpdate({ sessionId: this.sessionId, update: { sessionUpdate: "available_commands_update", - availableCommands: getAvailableSlashCommands(commands), + availableCommands: available, }, }); + this.updateBreakdownCategory("skills", estimateSkillsTokens(available)); + } + + /** Update one category of the context-breakdown baseline so the next + * `_posthog/usage_update` carries fresher numbers. No-op when the baseline + * hasn't been initialized yet (e.g. in a unit-test session). */ + private updateBreakdownCategory( + key: keyof NonNullable, + tokens: number, + ): void { + if (!this.session?.contextBreakdownBaseline) return; + if (this.session.contextBreakdownBaseline[key] === tokens) return; + this.session.contextBreakdownBaseline = { + ...this.session.contextBreakdownBaseline, + [key]: tokens, + }; } private async replaySessionHistory(sessionId: string): Promise { @@ -1610,6 +1672,10 @@ export class ClaudeAcpAgent extends BaseAcpAgent { this.sendAvailableCommandsUpdate(), ), fetchMcpToolMetadata(q, this.logger).then(() => { + this.updateBreakdownCategory( + "mcp", + estimateMcpTokens(getCachedMcpTools()), + ); const serverNames = getConnectedMcpServerNames(); if (serverNames.length > 0) { this.options?.onMcpServersReady?.(serverNames); diff --git a/packages/agent/src/adapters/claude/context-breakdown.test.ts b/packages/agent/src/adapters/claude/context-breakdown.test.ts new file mode 100644 index 0000000000..d1a5677e95 --- /dev/null +++ b/packages/agent/src/adapters/claude/context-breakdown.test.ts @@ -0,0 +1,130 @@ +import { describe, expect, it } from "vitest"; +import { + buildBreakdown, + emptyBaseline, + estimateJsonTokens, + estimateMcpTokens, + estimateRulesTokens, + estimateSkillsTokens, + estimateSystemPrompt, + estimateTokens, +} from "./context-breakdown"; + +describe("estimateTokens", () => { + it("returns 0 for empty input", () => { + expect(estimateTokens("")).toBe(0); + expect(estimateTokens(undefined)).toBe(0); + expect(estimateTokens(null)).toBe(0); + }); + + it("scales roughly with input length", () => { + expect(estimateTokens("a".repeat(35))).toBe(10); + expect(estimateTokens("a".repeat(350))).toBe(100); + }); +}); + +describe("estimateJsonTokens", () => { + it("counts JSON representation of objects", () => { + const tokens = estimateJsonTokens({ name: "Read", schema: { foo: 1 } }); + expect(tokens).toBeGreaterThan(0); + }); + + it("returns 0 for non-serializable values", () => { + const circular: Record = {}; + circular.self = circular; + expect(estimateJsonTokens(circular)).toBe(0); + }); +}); + +describe("estimateSystemPrompt", () => { + it("includes the Claude preset budget when preset is used", () => { + const noAppend = estimateSystemPrompt({ type: "preset" }); + expect(noAppend).toBeGreaterThan(0); + }); + + it("adds the append portion on top of the preset", () => { + const append = "a".repeat(350); + const result = estimateSystemPrompt({ type: "preset", append }); + const presetOnly = estimateSystemPrompt({ type: "preset" }); + expect(result - presetOnly).toBe(100); + }); + + it("counts a raw string verbatim with no preset overhead", () => { + expect(estimateSystemPrompt("a".repeat(350))).toBe(100); + }); + + it("treats undefined as the bare preset", () => { + expect(estimateSystemPrompt(undefined)).toBe( + estimateSystemPrompt({ type: "preset" }), + ); + }); +}); + +describe("estimateSkillsTokens", () => { + it("is 0 for an empty command list", () => { + expect(estimateSkillsTokens([])).toBe(0); + }); + + it("counts the JSON of name/description/hint", () => { + // [{"name":"review","description":"Review a PR","hint":"[pr]"}] ~ 55 chars + const result = estimateSkillsTokens([ + { name: "review", description: "Review a PR", input: { hint: "[pr]" } }, + ]); + expect(result).toBeGreaterThan(10); + expect(result).toBeLessThan(20); + }); +}); + +describe("estimateMcpTokens", () => { + it("is 0 for no connected tools", () => { + expect(estimateMcpTokens([])).toBe(0); + }); + + it("scales with tool count", () => { + const one = estimateMcpTokens([{ name: "get_user", description: "x" }]); + const many = estimateMcpTokens( + Array.from({ length: 50 }, (_, i) => ({ + name: `tool_${i}`, + description: "x", + })), + ); + expect(many).toBeGreaterThan(one * 10); + }); +}); + +describe("estimateRulesTokens", () => { + it("is 0 for missing rules", () => { + expect(estimateRulesTokens(undefined)).toBe(0); + expect(estimateRulesTokens("")).toBe(0); + }); + + it("counts the rules content", () => { + expect(estimateRulesTokens("a".repeat(350))).toBe(100); + }); +}); + +describe("buildBreakdown", () => { + it("derives conversation from input - stable sum", () => { + const baseline = { + ...emptyBaseline(), + systemPrompt: 4000, + tools: 500, + }; + const result = buildBreakdown(baseline, 10_000); + expect(result.systemPrompt).toBe(4000); + expect(result.tools).toBe(500); + expect(result.conversation).toBe(5500); + }); + + it("floors conversation at 0 when stable pieces exceed input", () => { + const baseline = { ...emptyBaseline(), systemPrompt: 5000 }; + expect(buildBreakdown(baseline, 1000).conversation).toBe(0); + }); + + it("includes zero categories", () => { + const result = buildBreakdown(emptyBaseline(), 100); + expect(result.mcp).toBe(0); + expect(result.skills).toBe(0); + expect(result.subagents).toBe(0); + }); +}); diff --git a/packages/agent/src/adapters/claude/context-breakdown.ts b/packages/agent/src/adapters/claude/context-breakdown.ts new file mode 100644 index 0000000000..421d47a45e --- /dev/null +++ b/packages/agent/src/adapters/claude/context-breakdown.ts @@ -0,0 +1,143 @@ +/** + * Per-source context-window token breakdown for the renderer's + * `ContextBreakdownPopover`. Anthropic doesn't break down `input_tokens` by + * source, so we tokenize the pieces we control client-side using a cheap + * character-ratio estimator (~3.5 chars/token). Numbers are indicative, not + * invoice-grade — used only for relative-share UX. + */ + +export type ContextCategory = + | "systemPrompt" + | "tools" + | "rules" + | "skills" + | "mcp" + | "subagents" + | "conversation"; + +export type ContextBreakdown = Record; + +// Rough estimate of Claude's bundled `claude_code` preset system prompt. The +// preset content is opaque to us so we add this constant when the systemPrompt +// uses the preset — otherwise it'd show up as Conversation and skew the chart. +const CLAUDE_PRESET_ESTIMATE_TOKENS = 4000; + +const CHARS_PER_TOKEN = 3.5; + +export function estimateTokens(text: string | undefined | null): number { + if (!text) return 0; + return Math.max(0, Math.round(text.length / CHARS_PER_TOKEN)); +} + +export function estimateJsonTokens(value: unknown): number { + try { + return estimateTokens(JSON.stringify(value)); + } catch { + return 0; + } +} + +interface SlashCommandLike { + name?: string; + description?: string; + input?: { hint?: string } | null; +} + +/** Tokens for the slash-command list the SDK injects into the system prompt. */ +export function estimateSkillsTokens(commands: SlashCommandLike[]): number { + if (!commands.length) return 0; + return estimateJsonTokens( + commands.map((c) => ({ + name: c.name, + description: c.description, + hint: c.input?.hint, + })), + ); +} + +interface McpToolLike { + name?: string; + description?: string; +} + +/** Tokens for the connected MCP tools' name + description. The SDK doesn't + * inject their full input schemas into the prompt by default (it relies on + * tool search), so this is a conservative estimate of what's resident. */ +export function estimateMcpTokens(tools: McpToolLike[]): number { + if (!tools.length) return 0; + return estimateJsonTokens( + tools.map((t) => ({ name: t.name, description: t.description })), + ); +} + +/** Tokens for the rules content appended to the system prompt (CLAUDE.md). */ +export function estimateRulesTokens(rules: string | undefined): number { + return estimateTokens(rules); +} + +export interface ContextBreakdownBaseline { + systemPrompt: number; + tools: number; + rules: number; + skills: number; + mcp: number; + subagents: number; +} + +export function emptyBaseline(): ContextBreakdownBaseline { + return { + systemPrompt: 0, + tools: 0, + rules: 0, + skills: 0, + mcp: 0, + subagents: 0, + }; +} + +/** + * Estimate tokens for whatever shape `Options["systemPrompt"]` ended up being: + * a raw string, a `{ type: "preset", append }` object, or undefined. + */ +export function estimateSystemPrompt(systemPrompt: unknown): number { + if (!systemPrompt) return CLAUDE_PRESET_ESTIMATE_TOKENS; + if (typeof systemPrompt === "string") return estimateTokens(systemPrompt); + if (typeof systemPrompt === "object") { + const obj = systemPrompt as { type?: string; append?: unknown }; + const appendTokens = + typeof obj.append === "string" ? estimateTokens(obj.append) : 0; + if (obj.type === "preset") { + return CLAUDE_PRESET_ESTIMATE_TOKENS + appendTokens; + } + return appendTokens; + } + return 0; +} + +/** + * Derive the per-source breakdown from a stable baseline + the current turn's + * input-token total. The conversation bucket is whatever is left after the + * stable pieces are subtracted; it's floored at 0 to absorb estimation drift. + */ +export function buildBreakdown( + baseline: ContextBreakdownBaseline, + currentInputTokens: number, +): ContextBreakdown { + const stableSum = + baseline.systemPrompt + + baseline.tools + + baseline.rules + + baseline.skills + + baseline.mcp + + baseline.subagents; + const conversation = Math.max(0, currentInputTokens - stableSum); + return { + systemPrompt: baseline.systemPrompt, + tools: baseline.tools, + rules: baseline.rules, + skills: baseline.skills, + mcp: baseline.mcp, + subagents: baseline.subagents, + conversation, + }; +} diff --git a/packages/agent/src/adapters/claude/mcp/tool-metadata.ts b/packages/agent/src/adapters/claude/mcp/tool-metadata.ts index 72ef0451ef..0f13d589ac 100644 --- a/packages/agent/src/adapters/claude/mcp/tool-metadata.ts +++ b/packages/agent/src/adapters/claude/mcp/tool-metadata.ts @@ -116,6 +116,12 @@ export function getConnectedMcpServerNames(): string[] { return [...names]; } +/** Snapshot of every tool currently in the metadata cache. Used by the + * context-breakdown estimator to size the MCP category. */ +export function getCachedMcpTools(): McpToolMetadata[] { + return [...mcpToolMetadataCache.values()]; +} + export function getMcpToolApprovalState( toolName: string, ): McpToolApprovalState | undefined { diff --git a/packages/agent/src/adapters/claude/types.ts b/packages/agent/src/adapters/claude/types.ts index da75bb62dc..b79fdb2bdb 100644 --- a/packages/agent/src/adapters/claude/types.ts +++ b/packages/agent/src/adapters/claude/types.ts @@ -10,6 +10,7 @@ import type { } from "@anthropic-ai/claude-agent-sdk"; import type { Pushable } from "../../utils/streams"; import type { BaseSession } from "../base-acp-agent"; +import type { ContextBreakdownBaseline } from "./context-breakdown"; import type { McpToolApprovals } from "./mcp/tool-metadata"; import type { SettingsManager } from "./session/settings"; import type { CodeExecutionMode } from "./tools"; @@ -65,6 +66,10 @@ export type Session = BaseSession & { pendingMessages: Map; nextPendingOrder: number; emitRawSDKMessages: boolean | SDKMessageFilter[]; + /** Per-source token estimates for stable pieces (system prompt, tools, etc.) + * used by the renderer's context-breakdown popover. Refreshed at session + * init and on MCP/skill changes. */ + contextBreakdownBaseline?: ContextBreakdownBaseline; }; export type ToolUseCache = { diff --git a/packages/agent/src/adapters/codex/codex-agent.ts b/packages/agent/src/adapters/codex/codex-agent.ts index 82746e7e53..d2e930b0ae 100644 --- a/packages/agent/src/adapters/codex/codex-agent.ts +++ b/packages/agent/src/adapters/codex/codex-agent.ts @@ -64,6 +64,12 @@ import { nodeWritableToWebWritable, } from "../../utils/streams"; import { BaseAcpAgent, type BaseSession } from "../base-acp-agent"; +import { + buildBreakdown, + type ContextBreakdownBaseline, + emptyBaseline, + estimateTokens, +} from "../claude/context-breakdown"; import { classifyAgentError } from "../error-classification"; import { enabledLocalTools, @@ -76,6 +82,7 @@ import { normalizeCodexConfigOptions } from "./models"; import { type CodexSessionState, createSessionState, + resetSessionState, resetUsage, } from "./session-state"; import { CodexSettingsManager } from "./settings"; @@ -161,6 +168,27 @@ function classifyPromptError(error: unknown): unknown { ); } +/** + * Codex inlines every MCP tool schema, every skill, and a sizable preset + * system prompt — none of which are visible to us without querying the MCP + * servers ourselves. We can only attribute the bits we control (our injected + * `meta.systemPrompt`) plus a constant for codex-core's BASELINE_TOKENS + * (`codex-rs/protocol/src/protocol.rs`, the always-resident floor). Everything + * else flows into the `conversation` bucket. The breakdown is indicative + * rather than itemised on Codex; for full attribution we'd need to query + * connected MCP servers' tool lists at session start. + */ +const CODEX_BASELINE_TOKENS = 12000; + +function buildCodexBaseline( + meta: NewSessionMeta | undefined, +): ContextBreakdownBaseline { + const baseline = emptyBaseline(); + baseline.systemPrompt = + CODEX_BASELINE_TOKENS + estimateTokens(meta?.systemPrompt); + return baseline; +} + const CODEX_NATIVE_MODE: Record = { auto: "auto", default: "auto", @@ -392,8 +420,10 @@ export class CodexAcpAgent extends BaseAcpAgent { response.configOptions, ); - // Initialize session state - this.sessionState = createSessionState(response.sessionId, params.cwd, { + // Initialize session state. Mutate in place — codex-client closure- + // captured this object in the constructor and writes contextUsed/ + // accumulatedUsage to it on every upstream usage_update. + resetSessionState(this.sessionState, response.sessionId, params.cwd, { taskRunId: meta?.taskRunId, taskId: resolveTaskId(meta), modeId: response.modes?.currentModeId ?? "auto", @@ -402,6 +432,7 @@ export class CodexAcpAgent extends BaseAcpAgent { }); this.sessionId = response.sessionId; this.sessionState.configOptions = response.configOptions ?? []; + this.sessionState.contextBreakdownBaseline = buildCodexBaseline(meta); await this.applyInitialPermissionMode( response.sessionId, @@ -445,7 +476,7 @@ export class CodexAcpAgent extends BaseAcpAgent { // notifications (TURN_COMPLETE, USAGE_UPDATE) after a reload. newSession // and unstable_resumeSession both do this; loadSession historically did // not, which silently broke task-completion tracking on re-attach. - this.sessionState = createSessionState(params.sessionId, params.cwd, { + resetSessionState(this.sessionState, params.sessionId, params.cwd, { taskRunId: meta?.taskRunId, taskId: resolveTaskId(meta), modeId: response.modes?.currentModeId ?? "auto", @@ -453,6 +484,7 @@ export class CodexAcpAgent extends BaseAcpAgent { }); this.sessionId = params.sessionId; this.sessionState.configOptions = response.configOptions ?? []; + this.sessionState.contextBreakdownBaseline = buildCodexBaseline(meta); if (meta?.taskRunId) { await this.client.extNotification(POSTHOG_NOTIFICATIONS.SDK_SESSION, { @@ -491,7 +523,7 @@ export class CodexAcpAgent extends BaseAcpAgent { loadResponse.modes?.currentModeId, meta?.permissionMode, ); - this.sessionState = createSessionState(params.sessionId, params.cwd, { + resetSessionState(this.sessionState, params.sessionId, params.cwd, { taskRunId: meta?.taskRunId, taskId: resolveTaskId(meta), modeId: loadResponse.modes?.currentModeId ?? "auto", @@ -499,6 +531,7 @@ export class CodexAcpAgent extends BaseAcpAgent { }); this.sessionId = params.sessionId; this.sessionState.configOptions = loadResponse.configOptions ?? []; + this.sessionState.contextBreakdownBaseline = buildCodexBaseline(meta); if (meta?.taskRunId) { await this.client.extNotification(POSTHOG_NOTIFICATIONS.SDK_SESSION, { @@ -538,7 +571,7 @@ export class CodexAcpAgent extends BaseAcpAgent { ); const requestedPermissionMode = toCodexPermissionMode(meta?.permissionMode); - this.sessionState = createSessionState(newResponse.sessionId, params.cwd, { + resetSessionState(this.sessionState, newResponse.sessionId, params.cwd, { taskRunId: meta?.taskRunId, taskId: resolveTaskId(meta), modeId: newResponse.modes?.currentModeId ?? "auto", @@ -546,6 +579,7 @@ export class CodexAcpAgent extends BaseAcpAgent { }); this.sessionId = newResponse.sessionId; this.sessionState.configOptions = newResponse.configOptions ?? []; + this.sessionState.contextBreakdownBaseline = buildCodexBaseline(meta); await this.applyInitialPermissionMode( newResponse.sessionId, @@ -730,6 +764,21 @@ export class CodexAcpAgent extends BaseAcpAgent { } } + // Emit the per-source breakdown so the renderer's ContextBreakdownPopover + // has data to show. This fires regardless of `taskRunId` (local sessions + // need it too) and regardless of `response.usage` (codex-acp doesn't + // populate it — context size comes from upstream usage_update events, + // tracked on sessionState.contextUsed). + if (this.sessionState.contextUsed !== undefined) { + await this.client.extNotification(POSTHOG_NOTIFICATIONS.USAGE_UPDATE, { + sessionId: params.sessionId, + breakdown: buildBreakdown( + this.sessionState.contextBreakdownBaseline ?? emptyBaseline(), + this.sessionState.contextUsed, + ), + }); + } + return response; } diff --git a/packages/agent/src/adapters/codex/codex-client.test.ts b/packages/agent/src/adapters/codex/codex-client.test.ts index 68ea50ad79..56a8d8b1d0 100644 --- a/packages/agent/src/adapters/codex/codex-client.test.ts +++ b/packages/agent/src/adapters/codex/codex-client.test.ts @@ -14,7 +14,7 @@ vi.mock("../../enrichment/file-enricher", () => ({ })); import { createCodexClient } from "./codex-client"; -import { createSessionState } from "./session-state"; +import { createSessionState, resetSessionState } from "./session-state"; function makeUpstream(response: ReadTextFileResponse): AgentSideConnection & { readTextFile: ReturnType; @@ -288,3 +288,51 @@ describe("createCodexClient onStructuredOutput", () => { expect(upstream.sessionUpdate).toHaveBeenCalledTimes(1); }); }); + +describe("createCodexClient usage_update propagation", () => { + const logger = new Logger({ debug: false, prefix: "[test]" }); + + function makeUpstream(): AgentSideConnection { + return { + sessionUpdate: vi.fn(async () => {}), + requestPermission: vi.fn(), + readTextFile: vi.fn(), + writeTextFile: vi.fn(), + createTerminal: vi.fn(), + terminalOutput: vi.fn(), + releaseTerminal: vi.fn(), + waitForTerminalExit: vi.fn(), + killTerminal: vi.fn(), + extMethod: vi.fn(), + extNotification: vi.fn(), + } as unknown as AgentSideConnection; + } + + // Regression: codex-client closure-captures the sessionState reference in + // its factory. CodexAcpAgent constructs the client once at startup with the + // initial "" sessionId state, then resetSessionState() mutates that same + // object on every newSession/loadSession/etc. If the agent ever reassigned + // `this.sessionState`, contextUsed writes would land on an orphan and the + // breakdown notification would never fire. + test("writes contextUsed to the same state object after resetSessionState", async () => { + const sessionState = createSessionState("", "/tmp"); + const upstream = makeUpstream(); + const client = createCodexClient(upstream, logger, sessionState); + + resetSessionState(sessionState, "real-session", "/tmp/repo", { + taskRunId: "run-1", + }); + + await client.sessionUpdate?.({ + sessionId: "real-session", + update: { + sessionUpdate: "usage_update", + used: 123_456, + size: 200_000, + }, + } as unknown as SessionNotification); + + expect(sessionState.contextUsed).toBe(123_456); + expect(sessionState.contextSize).toBe(200_000); + }); +}); diff --git a/packages/agent/src/adapters/codex/session-state.ts b/packages/agent/src/adapters/codex/session-state.ts index 1c68ee8d49..a0cec3f073 100644 --- a/packages/agent/src/adapters/codex/session-state.ts +++ b/packages/agent/src/adapters/codex/session-state.ts @@ -5,6 +5,7 @@ import type { SessionConfigOption } from "@agentclientprotocol/sdk"; import type { PermissionMode } from "../../execution-mode"; +import type { ContextBreakdownBaseline } from "../claude/context-breakdown"; export interface CodexUsage { inputTokens: number; @@ -22,6 +23,7 @@ export interface CodexSessionState { accumulatedUsage: CodexUsage; contextSize?: number; contextUsed?: number; + contextBreakdownBaseline?: ContextBreakdownBaseline; permissionMode: PermissionMode; taskRunId?: string; taskId?: string; @@ -56,6 +58,47 @@ export function createSessionState( }; } +/** + * Reset an existing session-state object in place. Used when codex-agent + * needs to recycle the same object reference across session lifecycle calls + * (newSession/loadSession/resumeSession/forkSession) — the codex-client + * closure-captures the original reference in createCodexClient(), so + * reassigning `agent.sessionState` would orphan it and silently break + * contextUsed/contextSize/accumulatedUsage propagation. Resets all fields + * createSessionState() would set; callers reassign optional extras + * (configOptions, contextBreakdownBaseline) right after. + */ +export function resetSessionState( + state: CodexSessionState, + sessionId: string, + cwd: string, + opts?: { + taskRunId?: string; + taskId?: string; + modeId?: string; + modelId?: string; + permissionMode?: PermissionMode; + }, +): void { + state.sessionId = sessionId; + state.cwd = cwd; + state.modeId = opts?.modeId ?? "auto"; + state.modelId = opts?.modelId; + state.configOptions = []; + state.accumulatedUsage = { + inputTokens: 0, + outputTokens: 0, + cachedReadTokens: 0, + cachedWriteTokens: 0, + }; + state.contextSize = undefined; + state.contextUsed = undefined; + state.contextBreakdownBaseline = undefined; + state.permissionMode = opts?.permissionMode ?? "auto"; + state.taskRunId = opts?.taskRunId; + state.taskId = opts?.taskId; +} + export function resetUsage(state: CodexSessionState): void { state.accumulatedUsage = { inputTokens: 0, From 248c521d765dd96c838399f93cbcc0d38c7b8e8a Mon Sep 17 00:00:00 2001 From: Peter Kirkham Date: Tue, 26 May 2026 14:10:17 +0100 Subject: [PATCH 2/3] fix: comments on pr --- apps/code/src/main/services/agent/schemas.ts | 2 - .../src/main/services/llm-gateway/schemas.ts | 6 +- .../main/services/usage-monitor/schemas.ts | 4 +- .../services/usage-monitor/service.test.ts | 30 ++++++-- .../main/services/usage-monitor/service.ts | 52 ++++++-------- .../src/main/trpc/routers/usage-monitor.ts | 3 - apps/code/src/renderer/App.tsx | 5 +- .../billing/components/SidebarUsageBar.tsx | 5 +- .../features/billing/hooks/useUsage.ts | 6 +- ...sageThresholdToast.ts => subscriptions.ts} | 19 ++--- .../renderer/features/billing/utils.test.ts | 71 +++++++++++-------- .../src/renderer/features/billing/utils.ts | 8 +-- .../components/ContextBreakdownPopover.tsx | 8 ++- .../components/sections/PlanUsageSettings.tsx | 4 +- .../adapters/claude/context-breakdown.test.ts | 10 +-- .../src/adapters/claude/context-breakdown.ts | 2 +- 16 files changed, 118 insertions(+), 117 deletions(-) rename apps/code/src/renderer/features/billing/{usageThresholdToast.ts => subscriptions.ts} (73%) diff --git a/apps/code/src/main/services/agent/schemas.ts b/apps/code/src/main/services/agent/schemas.ts index 95b09dc0e6..410d77ea59 100644 --- a/apps/code/src/main/services/agent/schemas.ts +++ b/apps/code/src/main/services/agent/schemas.ts @@ -203,8 +203,6 @@ export const AgentServiceEvent = { SessionsIdle: "sessions-idle", SessionIdleKilled: "session-idle-killed", AgentFileActivity: "agent-file-activity", - // Fires once per completed turn for both adapters. Consumed by - // UsageMonitorService to refresh billing usage without polling. LlmActivity: "llm-activity", } as const; diff --git a/apps/code/src/main/services/llm-gateway/schemas.ts b/apps/code/src/main/services/llm-gateway/schemas.ts index 06a735ca5d..b14fffd254 100644 --- a/apps/code/src/main/services/llm-gateway/schemas.ts +++ b/apps/code/src/main/services/llm-gateway/schemas.ts @@ -59,10 +59,7 @@ export interface AnthropicErrorResponse { export const usageBucketSchema = z.object({ used_percent: z.number(), - resets_in_seconds: z.number(), - // Absolute UTC reset timestamp from gateway A1; preferred over the - // rolling resets_in_seconds, which drifts between polls. - reset_at: z.string().datetime().optional(), + reset_at: z.string().datetime(), exceeded: z.boolean(), }); @@ -72,6 +69,7 @@ export const usageOutput = z.object({ sustained: usageBucketSchema, burst: usageBucketSchema, is_rate_limited: z.boolean(), + is_pro: z.boolean(), billing_period_end: z.string().datetime().nullable().optional(), }); diff --git a/apps/code/src/main/services/usage-monitor/schemas.ts b/apps/code/src/main/services/usage-monitor/schemas.ts index f3d3f7e456..dbfbde1631 100644 --- a/apps/code/src/main/services/usage-monitor/schemas.ts +++ b/apps/code/src/main/services/usage-monitor/schemas.ts @@ -14,9 +14,9 @@ export const thresholdCrossedEvent = z.object({ z.literal(100), ]), usedPercent: z.number(), - resetAt: z.string().datetime().nullable(), - resetsInSeconds: z.number(), + resetAt: z.string().datetime(), isPro: z.boolean(), + userIsActive: z.boolean(), }); export type ThresholdCrossedEvent = z.infer; diff --git a/apps/code/src/main/services/usage-monitor/service.test.ts b/apps/code/src/main/services/usage-monitor/service.test.ts index fbff9a80da..0308cb2d09 100644 --- a/apps/code/src/main/services/usage-monitor/service.test.ts +++ b/apps/code/src/main/services/usage-monitor/service.test.ts @@ -29,10 +29,12 @@ import type { AgentService } from "../agent/service"; import type { LlmGatewayService } from "../llm-gateway/service"; import { UsageMonitorService } from "./service"; -function makeAgentService() { - return new TypedEventEmitter<{ +function makeAgentService(opts?: { hasActiveSessions?: boolean }) { + const emitter = new TypedEventEmitter<{ [AgentServiceEvent.LlmActivity]: undefined; - }>() as unknown as AgentService; + }>() as unknown as AgentService & { hasActiveSessions: () => boolean }; + emitter.hasActiveSessions = () => opts?.hasActiveSessions ?? false; + return emitter; } function makeUsage(overrides?: { @@ -41,24 +43,24 @@ function makeUsage(overrides?: { billingPeriodEnd?: string | null; burstResetAt?: string; sustainedResetAt?: string; + isPro?: boolean; }): UsageOutput { return { product: "posthog_code", user_id: 42, is_rate_limited: false, + is_pro: overrides?.isPro ?? false, billing_period_end: overrides?.billingPeriodEnd === undefined ? null : overrides.billingPeriodEnd, burst: { used_percent: overrides?.burstPercent ?? 0, - resets_in_seconds: 3600, reset_at: overrides?.burstResetAt ?? "2026-05-25T16:00:00.000Z", exceeded: false, }, sustained: { used_percent: overrides?.sustainedPercent ?? 0, - resets_in_seconds: 86400, reset_at: overrides?.sustainedResetAt ?? "2026-06-01T00:00:00.000Z", exceeded: false, }, @@ -160,11 +162,12 @@ describe("UsageMonitorService", () => { ]); }); - it("marks events with isPro when billing_period_end is set", async () => { + it("marks events with isPro from the gateway", async () => { const events: { isPro: boolean }[] = []; const gateway = mockGateway( makeUsage({ sustainedPercent: 60, + isPro: true, billingPeriodEnd: "2026-06-01T00:00:00.000Z", }), ); @@ -177,6 +180,21 @@ describe("UsageMonitorService", () => { expect(events[0]?.isPro).toBe(true); }); + it("marks events with userIsActive from the agent service", async () => { + const events: { userIsActive: boolean }[] = []; + const gateway = mockGateway(makeUsage({ burstPercent: 78 })); + service = new UsageMonitorService( + gateway, + makeAgentService({ hasActiveSessions: true }), + ); + service.on(UsageMonitorEvent.ThresholdCrossed, (e) => + events.push(e as { userIsActive: boolean }), + ); + + await service.fetchOnce(); + expect(events[0]?.userIsActive).toBe(true); + }); + it("silently skips polls when the gateway throws", async () => { const events: unknown[] = []; const gateway = { diff --git a/apps/code/src/main/services/usage-monitor/service.ts b/apps/code/src/main/services/usage-monitor/service.ts index 281a547910..2d90c73ae9 100644 --- a/apps/code/src/main/services/usage-monitor/service.ts +++ b/apps/code/src/main/services/usage-monitor/service.ts @@ -111,7 +111,14 @@ export class UsageMonitorService extends TypedEventEmitter { this.coalesceTimeoutId = null; } try { - const usage = await this.fetchUsageQuietly(); + let usage: UsageOutput | null = null; + try { + usage = await this.llmGateway.fetchUsage(); + } catch (err) { + log.debug("Usage fetch skipped", { + error: err instanceof Error ? err.message : String(err), + }); + } if (usage) { const changed = !isSameUsage(this.latestUsage, usage); this.latestUsage = usage; @@ -126,17 +133,6 @@ export class UsageMonitorService extends TypedEventEmitter { } } - private async fetchUsageQuietly(): Promise { - try { - return await this.llmGateway.fetchUsage(); - } catch (err) { - log.debug("Usage fetch skipped", { - error: err instanceof Error ? err.message : String(err), - }); - return null; - } - } - private scheduleBackstop(): void { this.backstopTimeoutId = setTimeout(async () => { this.backstopTimeoutId = null; @@ -148,14 +144,15 @@ export class UsageMonitorService extends TypedEventEmitter { private processUsage(usage: UsageOutput): void { const userId = usage.user_id.toString(); const product = usage.product; - // Plan-key isn't on UsageOutput; the only signal we have client-side is - // whether limits are at the Pro tier — but fetchUsage doesn't return that - // either. Best-effort: assume Pro if billing_period_end is present - // (free users never have it). - const isPro = !!usage.billing_period_end; - - this.maybeEmit(usage, "burst", usage.burst, userId, product, isPro); - this.maybeEmit(usage, "sustained", usage.sustained, userId, product, isPro); + this.maybeEmit(usage, "burst", usage.burst, userId, product, usage.is_pro); + this.maybeEmit( + usage, + "sustained", + usage.sustained, + userId, + product, + usage.is_pro, + ); } private maybeEmit( @@ -188,9 +185,9 @@ export class UsageMonitorService extends TypedEventEmitter { bucket, threshold, usedPercent: status.used_percent, - resetAt: status.reset_at ?? null, - resetsInSeconds: status.resets_in_seconds, + resetAt: status.reset_at, isPro, + userIsActive: this.agentService.hasActiveSessions(), }); } @@ -247,14 +244,8 @@ function sustainedFreeAnchor(status: UsageBucket): string | null { } function resetMillis(status: UsageBucket): number | null { - if (status.reset_at) { - const parsed = Date.parse(status.reset_at); - if (!Number.isNaN(parsed)) return parsed; - } - if (status.resets_in_seconds > 0) { - return Date.now() + status.resets_in_seconds * 1000; - } - return null; + const parsed = Date.parse(status.reset_at); + return Number.isNaN(parsed) ? null : parsed; } function makeKey( @@ -280,7 +271,6 @@ function isSameUsage(a: UsageOutput | null, b: UsageOutput): boolean { function isSameBucket(a: UsageBucket, b: UsageBucket): boolean { return ( a.used_percent === b.used_percent && - a.resets_in_seconds === b.resets_in_seconds && a.reset_at === b.reset_at && a.exceeded === b.exceeded ); diff --git a/apps/code/src/main/trpc/routers/usage-monitor.ts b/apps/code/src/main/trpc/routers/usage-monitor.ts index ef3c63ab9e..6775e57d2f 100644 --- a/apps/code/src/main/trpc/routers/usage-monitor.ts +++ b/apps/code/src/main/trpc/routers/usage-monitor.ts @@ -23,10 +23,7 @@ function subscribe(event: K) { export const usageMonitorRouter = router({ onThresholdCrossed: subscribe(UsageMonitorEvent.ThresholdCrossed), - // Stream of full usage snapshots — replaces the renderer's 30s poll. onUsageUpdated: subscribe(UsageMonitorEvent.UsageUpdated), - // Cached snapshot for the renderer to bootstrap before the first event - // arrives. Null until the first poll completes. getLatest: publicProcedure .output(usageSnapshotOutput) .query(() => getService().getLatest()), diff --git a/apps/code/src/renderer/App.tsx b/apps/code/src/renderer/App.tsx index 05de56b05a..56ef6c5cfa 100644 --- a/apps/code/src/renderer/App.tsx +++ b/apps/code/src/renderer/App.tsx @@ -12,7 +12,7 @@ import { } from "@features/auth/hooks/authQueries"; import { useAuthSession } from "@features/auth/hooks/useAuthSession"; import { useIsOrgAdmin } from "@features/auth/hooks/useOrgRole"; -import { initializeUsageThresholdToast } from "@features/billing/usageThresholdToast"; +import { registerBillingSubscriptions } from "@features/billing/subscriptions"; import { AddDirectoryDialog } from "@features/folder-picker/components/AddDirectoryDialog"; import { OnboardingFlow } from "@features/onboarding/components/OnboardingFlow"; import { useOnboardingStore } from "@features/onboarding/stores/onboardingStore"; @@ -64,10 +64,9 @@ function App() { }; }, []); - // Initialize usage threshold notifications (50/75/90/100%) useEffect(() => { if (!isAuthenticated) return; - return initializeUsageThresholdToast(); + return registerBillingSubscriptions(); }, [isAuthenticated]); // Initialize update store diff --git a/apps/code/src/renderer/features/billing/components/SidebarUsageBar.tsx b/apps/code/src/renderer/features/billing/components/SidebarUsageBar.tsx index c18b21d12a..e2dfdcb60f 100644 --- a/apps/code/src/renderer/features/billing/components/SidebarUsageBar.tsx +++ b/apps/code/src/renderer/features/billing/components/SidebarUsageBar.tsx @@ -33,10 +33,7 @@ export function SidebarUsageBar() { ? usage.sustained : usage.burst; const usagePercent = Math.min(Math.round(dominant.used_percent), 100); - const resetLabel = formatResetTime( - dominant.reset_at, - dominant.resets_in_seconds, - ); + const resetLabel = formatResetTime(dominant.reset_at); return (
diff --git a/apps/code/src/renderer/features/billing/hooks/useUsage.ts b/apps/code/src/renderer/features/billing/hooks/useUsage.ts index 3eee75bca6..6d843aa866 100644 --- a/apps/code/src/renderer/features/billing/hooks/useUsage.ts +++ b/apps/code/src/renderer/features/billing/hooks/useUsage.ts @@ -15,7 +15,7 @@ export function useUsage({ enabled = true }: { enabled?: boolean } = {}) { ...trpc.usageMonitor.getLatest.queryOptions(), enabled, }); - const refreshMutation = useMutation( + const { mutateAsync: refreshUsage } = useMutation( trpc.usageMonitor.refresh.mutationOptions(), ); @@ -29,12 +29,12 @@ export function useUsage({ enabled = true }: { enabled?: boolean } = {}) { ); const refetch = useCallback(async () => { - const fresh = await refreshMutation.mutateAsync(); + const fresh = await refreshUsage(); if (fresh) { queryClient.setQueryData(trpc.usageMonitor.getLatest.queryKey(), fresh); } return fresh; - }, [refreshMutation, queryClient, trpc.usageMonitor.getLatest]); + }, [refreshUsage, queryClient, trpc.usageMonitor.getLatest]); return { usage: query.data ?? null, diff --git a/apps/code/src/renderer/features/billing/usageThresholdToast.ts b/apps/code/src/renderer/features/billing/subscriptions.ts similarity index 73% rename from apps/code/src/renderer/features/billing/usageThresholdToast.ts rename to apps/code/src/renderer/features/billing/subscriptions.ts index d6b3a005cb..433d3c9ea9 100644 --- a/apps/code/src/renderer/features/billing/usageThresholdToast.ts +++ b/apps/code/src/renderer/features/billing/subscriptions.ts @@ -1,36 +1,25 @@ import { useUsageLimitStore } from "@features/billing/stores/usageLimitStore"; import { formatResetTime } from "@features/billing/utils"; -import { useSessionStore } from "@features/sessions/stores/sessionStore"; import { useSettingsDialogStore } from "@features/settings/stores/settingsDialogStore"; import { trpcClient } from "@renderer/trpc/client"; import { logger } from "@utils/logger"; import { toast } from "@utils/toast"; -const log = logger.scope("usage-threshold-toast"); +const log = logger.scope("billing-subscriptions"); const openPlanUsage = () => { useSettingsDialogStore.getState().open("plan-usage"); }; -function hasActiveSession(): boolean { - const sessions = useSessionStore.getState().sessions; - return Object.values(sessions).some( - (s) => s.status === "connected" && s.isPromptPending, - ); -} - -export function initializeUsageThresholdToast() { +export function registerBillingSubscriptions() { const subscription = trpcClient.usageMonitor.onThresholdCrossed.subscribe( undefined, { onData: (event) => { - const resetLabel = formatResetTime( - event.resetAt ?? undefined, - event.resetsInSeconds, - ); + const resetLabel = formatResetTime(event.resetAt); if (event.threshold === 100) { - if (hasActiveSession()) { + if (event.userIsActive) { useUsageLimitStore.getState().show(); return; } diff --git a/apps/code/src/renderer/features/billing/utils.test.ts b/apps/code/src/renderer/features/billing/utils.test.ts index 3a6f6891d1..0b9ed02d71 100644 --- a/apps/code/src/renderer/features/billing/utils.test.ts +++ b/apps/code/src/renderer/features/billing/utils.test.ts @@ -14,15 +14,16 @@ function makeUsage( user_id: 1, sustained: { used_percent: 50, - resets_in_seconds: 3600, + reset_at: "2026-05-01T13:00:00.000Z", exceeded: overrides.sustained ?? false, }, burst: { used_percent: 30, - resets_in_seconds: 600, + reset_at: "2026-05-01T12:10:00.000Z", exceeded: overrides.burst ?? false, }, is_rate_limited: overrides.isRateLimited ?? false, + is_pro: false, }; } @@ -54,33 +55,45 @@ describe("isUsageExceeded", () => { describe("formatResetTime", () => { const NOW = Date.parse("2026-05-01T12:00:00.000Z"); + const isoAt = (msFromNow: number) => new Date(NOW + msFromNow).toISOString(); - it("returns minutes-only under 1h", () => { - expect(formatResetTime(undefined, 30 * 60, NOW)).toBe("Resets in 30m"); - }); - - it("returns hours + minutes under 24h", () => { - expect(formatResetTime(undefined, 4 * 3600 + 30 * 60, NOW)).toBe( - "Resets in 4h 30m", - ); - }); - - it("returns hours only when minutes round to 0", () => { - expect(formatResetTime(undefined, 4 * 3600, NOW)).toBe("Resets in 4h"); - }); - - it("returns localized date when over 24h away", () => { - const result = formatResetTime(undefined, 30 * 86400, NOW); - expect(result).toMatch(/^Resets [A-Za-z]+ \d+ at /); - }); - - it("prefers reset_at over the fallback seconds", () => { - const iso = new Date(NOW + 4 * 3600 * 1000).toISOString(); - expect(formatResetTime(iso, 99999, NOW)).toBe("Resets in 4h"); - }); - - it("treats an already-past reset_at as shortly", () => { - const iso = new Date(NOW - 60_000).toISOString(); - expect(formatResetTime(iso, 0, NOW)).toBe("Resets shortly"); + it.each([ + { + name: "returns minutes-only under 1h", + resetAt: isoAt(30 * 60 * 1000), + expected: "Resets in 30m" as string | RegExp, + }, + { + name: "returns hours + minutes under 24h", + resetAt: isoAt((4 * 3600 + 30 * 60) * 1000), + expected: "Resets in 4h 30m", + }, + { + name: "returns hours only when minutes round to 0", + resetAt: isoAt(4 * 3600 * 1000), + expected: "Resets in 4h", + }, + { + name: "returns localized date when over 24h away", + resetAt: isoAt(30 * 86400 * 1000), + expected: /^Resets [A-Za-z]+ \d+ at /, + }, + { + name: "treats an already-past reset_at as shortly", + resetAt: isoAt(-60_000), + expected: "Resets shortly", + }, + { + name: "treats an unparseable reset_at as shortly", + resetAt: "not-a-date", + expected: "Resets shortly", + }, + ])("$name", ({ resetAt, expected }) => { + const result = formatResetTime(resetAt, NOW); + if (expected instanceof RegExp) { + expect(result).toMatch(expected); + } else { + expect(result).toBe(expected); + } }); }); diff --git a/apps/code/src/renderer/features/billing/utils.ts b/apps/code/src/renderer/features/billing/utils.ts index 10117b10d3..7db7af0415 100644 --- a/apps/code/src/renderer/features/billing/utils.ts +++ b/apps/code/src/renderer/features/billing/utils.ts @@ -7,13 +7,11 @@ export function isUsageExceeded(usage: UsageOutput): boolean { } export function formatResetTime( - resetAtIso: string | undefined, - fallbackSeconds: number, + resetAtIso: string, now: number = Date.now(), ): string { - const ms = resetAtIso - ? Math.max(0, Date.parse(resetAtIso) - now) - : Math.max(0, fallbackSeconds * 1000); + const parsed = Date.parse(resetAtIso); + const ms = Number.isNaN(parsed) ? 0 : Math.max(0, parsed - now); const totalMinutes = Math.ceil(ms / 60_000); if (totalMinutes <= 0) return "Resets shortly"; diff --git a/apps/code/src/renderer/features/sessions/components/ContextBreakdownPopover.tsx b/apps/code/src/renderer/features/sessions/components/ContextBreakdownPopover.tsx index 5ecbe90fda..24cc8803c8 100644 --- a/apps/code/src/renderer/features/sessions/components/ContextBreakdownPopover.tsx +++ b/apps/code/src/renderer/features/sessions/components/ContextBreakdownPopover.tsx @@ -80,6 +80,12 @@ function SegmentedBar({ if (total <= 0) { return
; } + + const segmentSum = CONTEXT_CATEGORIES.reduce( + (acc, cat) => acc + Math.max(0, breakdown[cat.key]), + 0, + ); + const denominator = Math.max(total, segmentSum); return (
{CONTEXT_CATEGORIES.map((cat) => { @@ -89,7 +95,7 @@ function SegmentedBar({
diff --git a/apps/code/src/renderer/features/settings/components/sections/PlanUsageSettings.tsx b/apps/code/src/renderer/features/settings/components/sections/PlanUsageSettings.tsx index 62cdc3dcc6..b3001f596b 100644 --- a/apps/code/src/renderer/features/settings/components/sections/PlanUsageSettings.tsx +++ b/apps/code/src/renderer/features/settings/components/sections/PlanUsageSettings.tsx @@ -440,9 +440,7 @@ function UsageMeter({ label, bucket, color }: UsageMeterProps) { color={color === "red" ? "red" : undefined} /> - {bucket.exceeded - ? "Limit exceeded" - : formatResetTime(bucket.reset_at, bucket.resets_in_seconds)} + {bucket.exceeded ? "Limit exceeded" : formatResetTime(bucket.reset_at)} ); diff --git a/packages/agent/src/adapters/claude/context-breakdown.test.ts b/packages/agent/src/adapters/claude/context-breakdown.test.ts index d1a5677e95..4e2c27a449 100644 --- a/packages/agent/src/adapters/claude/context-breakdown.test.ts +++ b/packages/agent/src/adapters/claude/context-breakdown.test.ts @@ -18,8 +18,8 @@ describe("estimateTokens", () => { }); it("scales roughly with input length", () => { - expect(estimateTokens("a".repeat(35))).toBe(10); - expect(estimateTokens("a".repeat(350))).toBe(100); + expect(estimateTokens("a".repeat(40))).toBe(10); + expect(estimateTokens("a".repeat(400))).toBe(100); }); }); @@ -43,14 +43,14 @@ describe("estimateSystemPrompt", () => { }); it("adds the append portion on top of the preset", () => { - const append = "a".repeat(350); + const append = "a".repeat(400); const result = estimateSystemPrompt({ type: "preset", append }); const presetOnly = estimateSystemPrompt({ type: "preset" }); expect(result - presetOnly).toBe(100); }); it("counts a raw string verbatim with no preset overhead", () => { - expect(estimateSystemPrompt("a".repeat(350))).toBe(100); + expect(estimateSystemPrompt("a".repeat(400))).toBe(100); }); it("treats undefined as the bare preset", () => { @@ -99,7 +99,7 @@ describe("estimateRulesTokens", () => { }); it("counts the rules content", () => { - expect(estimateRulesTokens("a".repeat(350))).toBe(100); + expect(estimateRulesTokens("a".repeat(400))).toBe(100); }); }); diff --git a/packages/agent/src/adapters/claude/context-breakdown.ts b/packages/agent/src/adapters/claude/context-breakdown.ts index 421d47a45e..69fce7cba1 100644 --- a/packages/agent/src/adapters/claude/context-breakdown.ts +++ b/packages/agent/src/adapters/claude/context-breakdown.ts @@ -22,7 +22,7 @@ export type ContextBreakdown = Record; // uses the preset — otherwise it'd show up as Conversation and skew the chart. const CLAUDE_PRESET_ESTIMATE_TOKENS = 4000; -const CHARS_PER_TOKEN = 3.5; +const CHARS_PER_TOKEN = 4; export function estimateTokens(text: string | undefined | null): number { if (!text) return 0; From 360c976af4753d9ce5262f906841d9e035a666cc Mon Sep 17 00:00:00 2001 From: Peter Kirkham Date: Tue, 26 May 2026 14:32:07 +0100 Subject: [PATCH 3/3] remove slop comments --- .../services/usage-monitor/service.test.ts | 8 ----- .../main/services/usage-monitor/service.ts | 27 ++++------------ .../features/billing/hooks/useUsage.ts | 5 --- .../sessions/hooks/useContextUsage.ts | 6 ++-- .../features/sessions/utils/contextColors.ts | 3 -- .../agent/src/adapters/claude/claude-agent.ts | 13 +++----- .../src/adapters/claude/context-breakdown.ts | 32 +++++-------------- packages/agent/src/adapters/claude/types.ts | 4 +-- .../agent/src/adapters/codex/codex-agent.ts | 12 ++----- .../agent/src/adapters/codex/session-state.ts | 18 ++--------- 10 files changed, 26 insertions(+), 102 deletions(-) diff --git a/apps/code/src/main/services/usage-monitor/service.test.ts b/apps/code/src/main/services/usage-monitor/service.test.ts index 0308cb2d09..6132a8851a 100644 --- a/apps/code/src/main/services/usage-monitor/service.test.ts +++ b/apps/code/src/main/services/usage-monitor/service.test.ts @@ -135,7 +135,6 @@ describe("UsageMonitorService", () => { expect(events).toHaveLength(1); service.stop(); - // Simulate relaunch service = new UsageMonitorService(gateway, makeAgentService()); service.on(UsageMonitorEvent.ThresholdCrossed, (e) => events.push(e)); await service.fetchOnce(); @@ -224,11 +223,9 @@ describe("UsageMonitorService", () => { expect(updates).toHaveLength(1); expect(service.getLatest()?.burst.used_percent).toBe(20); - // Identical snapshot — no re-emit. await service.fetchOnce(); expect(updates).toHaveLength(1); - // Genuine change — re-emits. await service.fetchOnce(); expect(updates).toHaveLength(2); expect(updates[1].burst.used_percent).toBe(35); @@ -261,24 +258,19 @@ describe("UsageMonitorService", () => { const agent = makeAgentService(); service = new UsageMonitorService(gateway, agent); service.init(); - // Drain bootstrap (a microtask, not a timer). await vi.advanceTimersByTimeAsync(0); expect(gateway.fetchUsage).toHaveBeenCalledTimes(1); - // Burst of 4 parallel agents finishing within the coalesce window. agent.emit(AgentServiceEvent.LlmActivity, undefined); agent.emit(AgentServiceEvent.LlmActivity, undefined); agent.emit(AgentServiceEvent.LlmActivity, undefined); agent.emit(AgentServiceEvent.LlmActivity, undefined); - // Nothing fires immediately — the coalesce window holds. await vi.advanceTimersByTimeAsync(0); expect(gateway.fetchUsage).toHaveBeenCalledTimes(1); - // Advance past the coalesce window — exactly one trailing fetch fires. await vi.advanceTimersByTimeAsync(5_000); expect(gateway.fetchUsage).toHaveBeenCalledTimes(2); - // A long gap, then a single event — fires once after the window. await vi.advanceTimersByTimeAsync(60_000); agent.emit(AgentServiceEvent.LlmActivity, undefined); await vi.advanceTimersByTimeAsync(5_000); diff --git a/apps/code/src/main/services/usage-monitor/service.ts b/apps/code/src/main/services/usage-monitor/service.ts index 2d90c73ae9..41f02bc121 100644 --- a/apps/code/src/main/services/usage-monitor/service.ts +++ b/apps/code/src/main/services/usage-monitor/service.ts @@ -16,12 +16,9 @@ import { usageMonitorStore } from "./store"; const log = logger.scope("usage-monitor"); -// Coalesce bursts (e.g. 4 parallel agents finishing turns) into one trailing -// fetch per window. const COALESCE_INTERVAL_MS = 5_000; - -// Safety net for billing-period rollovers while the app sits idle and no -// LlmActivity events fire. +// Catches reset-window rollovers and out-of-band plan changes while the app +// sits idle and no LlmActivity events fire. const BACKSTOP_INTERVAL_MS = 30 * 60_000; type BucketName = "burst" | "sustained"; @@ -32,8 +29,6 @@ export class UsageMonitorService extends TypedEventEmitter { private coalesceTimeoutId: ReturnType | null = null; private lastFetchStartedAt = 0; private isFetching = false; - // Snapshot of the most recent thresholdsSeen map so we hit electron-store - // only when we actually persist a new threshold. private thresholdsSeen: Record; private latestUsage: UsageOutput | null = null; @@ -49,22 +44,16 @@ export class UsageMonitorService extends TypedEventEmitter { this.thresholdsSeen = { ...usageMonitorStore.get("thresholdsSeen", {}) }; } - /** Last successful usage snapshot; null until the first fetch succeeds. */ getLatest(): UsageOutput | null { return this.latestUsage; } - /** Trigger an immediate refresh, returning the resulting snapshot. */ async refreshNow(): Promise { return this.fetchOnce(); } - /** - * Request a refresh in response to agent activity (turn-complete events). - * Coalesces bursts so N parallel agents finishing in quick succession - * produce at most two fetches (leading + trailing) per `COALESCE_INTERVAL_MS` - * window. Safe to call from many call sites with no rate-limit awareness. - */ + // Coalesces N parallel agents finishing turns into at most two fetches + // (leading + trailing) per `COALESCE_INTERVAL_MS` window. requestRefresh(): void { if (this.coalesceTimeoutId) return; const now = Date.now(); @@ -82,7 +71,6 @@ export class UsageMonitorService extends TypedEventEmitter { init(): void { this.pruneStaleEntries(); this.agentService.on(AgentServiceEvent.LlmActivity, this.onLlmActivity); - // Bootstrap so the UI doesn't show null until the first agent turn. void this.fetchOnce(); this.scheduleBackstop(); } @@ -104,8 +92,6 @@ export class UsageMonitorService extends TypedEventEmitter { if (this.isFetching) return null; this.isFetching = true; this.lastFetchStartedAt = Date.now(); - // Any pending coalesced fetch is satisfied by this one — drop it so the - // backstop and refreshNow() paths don't trigger a redundant follow-up. if (this.coalesceTimeoutId) { clearTimeout(this.coalesceTimeoutId); this.coalesceTimeoutId = null; @@ -191,9 +177,8 @@ export class UsageMonitorService extends TypedEventEmitter { }); } - // Burst anchor rounds reset_at to the hour so transient TTL jitter doesn't - // make every poll look like a new window. Sustained anchor is the billing - // period end (Pro) or the reset_at ISO date (free). + // Rounded anchor so transient TTL jitter doesn't make every poll look like + // a fresh window. private anchorFor( bucket: BucketName, status: UsageBucket, diff --git a/apps/code/src/renderer/features/billing/hooks/useUsage.ts b/apps/code/src/renderer/features/billing/hooks/useUsage.ts index 6d843aa866..2b6af06c72 100644 --- a/apps/code/src/renderer/features/billing/hooks/useUsage.ts +++ b/apps/code/src/renderer/features/billing/hooks/useUsage.ts @@ -3,11 +3,6 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useSubscription } from "@trpc/tanstack-react-query"; import { useCallback } from "react"; -/** - * Subscribe to usage snapshots pushed by the main-process `UsageMonitorService`. - * The service is the single source of truth — it pushes fresh snapshots after - * every agent turn (via the LlmActivity event) plus a 30-minute backstop. - */ export function useUsage({ enabled = true }: { enabled?: boolean } = {}) { const trpc = useTRPC(); const queryClient = useQueryClient(); diff --git a/apps/code/src/renderer/features/sessions/hooks/useContextUsage.ts b/apps/code/src/renderer/features/sessions/hooks/useContextUsage.ts index 8c1e2dcc88..73a8c68623 100644 --- a/apps/code/src/renderer/features/sessions/hooks/useContextUsage.ts +++ b/apps/code/src/renderer/features/sessions/hooks/useContextUsage.ts @@ -1,10 +1,8 @@ import type { AcpMessage } from "@shared/types/session-events"; import { useMemo } from "react"; -// Shape mirrors `ContextBreakdown` emitted by the agent in -// `_posthog/usage_update` (see packages/agent/src/adapters/claude/context-breakdown.ts). -// Kept local to avoid a renderer dependency on the agent package; if the shape -// drifts, lift it into @posthog/shared. +// Duplicated rather than imported from `packages/agent` to keep the renderer +// off that dep; lift into `@posthog/shared` if the shape drifts. export interface ContextBreakdown { systemPrompt: number; tools: number; diff --git a/apps/code/src/renderer/features/sessions/utils/contextColors.ts b/apps/code/src/renderer/features/sessions/utils/contextColors.ts index befe85260f..fa8f27f5cc 100644 --- a/apps/code/src/renderer/features/sessions/utils/contextColors.ts +++ b/apps/code/src/renderer/features/sessions/utils/contextColors.ts @@ -6,9 +6,6 @@ export interface CategoryStyle { color: string; } -// Ordered like the design spec: System prompt, Tools, Rules, Skills, MCP, -// Subagents, Conversation. Colors reuse Radix scales so they read in both -// light/dark modes. export const CONTEXT_CATEGORIES: readonly CategoryStyle[] = [ { key: "systemPrompt", label: "System prompt", color: "var(--gray-9)" }, { key: "tools", label: "Tools", color: "var(--violet-9)" }, diff --git a/packages/agent/src/adapters/claude/claude-agent.ts b/packages/agent/src/adapters/claude/claude-agent.ts index a307e5a5bf..def341f56b 100644 --- a/packages/agent/src/adapters/claude/claude-agent.ts +++ b/packages/agent/src/adapters/claude/claude-agent.ts @@ -127,9 +127,8 @@ const SESSION_VALIDATION_TIMEOUT_MS = 30_000; const MAX_TITLE_LENGTH = 256; const LOCAL_ONLY_COMMANDS = new Set(["/context", "/heapdump", "/extra-usage"]); -/** Read CLAUDE.md from the project root so the context breakdown can size the - * Rules category. Best-effort: silent on a missing file, logs otherwise so - * permission errors aren't lost. */ +// Best-effort: silent on ENOENT, logs other errors so permission failures +// aren't masked. function readClaudeMdQuietly(cwd: string, logger: Logger): string | undefined { try { return fs.readFileSync(path.join(cwd, "CLAUDE.md"), "utf-8"); @@ -582,12 +581,8 @@ export class ClaudeAcpAgent extends BaseAcpAgent { }); } - // Use the latest outermost-model snapshot, not `message.usage`. - // The SDK's result usage is cumulative across every round of the - // agentic loop (each tool-use iteration re-counts the prompt), so - // it overstates the resident context several-fold. `lastStreamUsage` - // tracks the most recent `message_start`/`message_delta` for the - // outermost model, which is what's actually sitting in the window. + // `result.usage` is cumulative across the agentic loop; the + // outermost-model stream snapshot is what's actually resident. const breakdownInputTokens = lastStreamUsage.input_tokens + lastStreamUsage.cache_read_input_tokens + diff --git a/packages/agent/src/adapters/claude/context-breakdown.ts b/packages/agent/src/adapters/claude/context-breakdown.ts index 69fce7cba1..4c2c2d7213 100644 --- a/packages/agent/src/adapters/claude/context-breakdown.ts +++ b/packages/agent/src/adapters/claude/context-breakdown.ts @@ -1,10 +1,5 @@ -/** - * Per-source context-window token breakdown for the renderer's - * `ContextBreakdownPopover`. Anthropic doesn't break down `input_tokens` by - * source, so we tokenize the pieces we control client-side using a cheap - * character-ratio estimator (~3.5 chars/token). Numbers are indicative, not - * invoice-grade — used only for relative-share UX. - */ +// Anthropic doesn't break `input_tokens` down by source; we estimate the bits +// we control via a chars-per-token heuristic. Indicative, not invoice-grade. export type ContextCategory = | "systemPrompt" @@ -17,9 +12,8 @@ export type ContextCategory = export type ContextBreakdown = Record; -// Rough estimate of Claude's bundled `claude_code` preset system prompt. The -// preset content is opaque to us so we add this constant when the systemPrompt -// uses the preset — otherwise it'd show up as Conversation and skew the chart. +// The `claude_code` preset prompt is opaque to us; without this constant its +// tokens would bleed into the Conversation bucket and skew the chart. const CLAUDE_PRESET_ESTIMATE_TOKENS = 4000; const CHARS_PER_TOKEN = 4; @@ -43,7 +37,6 @@ interface SlashCommandLike { input?: { hint?: string } | null; } -/** Tokens for the slash-command list the SDK injects into the system prompt. */ export function estimateSkillsTokens(commands: SlashCommandLike[]): number { if (!commands.length) return 0; return estimateJsonTokens( @@ -60,9 +53,8 @@ interface McpToolLike { description?: string; } -/** Tokens for the connected MCP tools' name + description. The SDK doesn't - * inject their full input schemas into the prompt by default (it relies on - * tool search), so this is a conservative estimate of what's resident. */ +// The SDK relies on tool search rather than inlining full MCP schemas in the +// prompt, so name + description is a conservative estimate of what's resident. export function estimateMcpTokens(tools: McpToolLike[]): number { if (!tools.length) return 0; return estimateJsonTokens( @@ -70,7 +62,6 @@ export function estimateMcpTokens(tools: McpToolLike[]): number { ); } -/** Tokens for the rules content appended to the system prompt (CLAUDE.md). */ export function estimateRulesTokens(rules: string | undefined): number { return estimateTokens(rules); } @@ -95,10 +86,6 @@ export function emptyBaseline(): ContextBreakdownBaseline { }; } -/** - * Estimate tokens for whatever shape `Options["systemPrompt"]` ended up being: - * a raw string, a `{ type: "preset", append }` object, or undefined. - */ export function estimateSystemPrompt(systemPrompt: unknown): number { if (!systemPrompt) return CLAUDE_PRESET_ESTIMATE_TOKENS; if (typeof systemPrompt === "string") return estimateTokens(systemPrompt); @@ -114,11 +101,8 @@ export function estimateSystemPrompt(systemPrompt: unknown): number { return 0; } -/** - * Derive the per-source breakdown from a stable baseline + the current turn's - * input-token total. The conversation bucket is whatever is left after the - * stable pieces are subtracted; it's floored at 0 to absorb estimation drift. - */ +// Conversation is floored at 0 so estimation drift in the stable categories +// can't surface a negative bucket. export function buildBreakdown( baseline: ContextBreakdownBaseline, currentInputTokens: number, diff --git a/packages/agent/src/adapters/claude/types.ts b/packages/agent/src/adapters/claude/types.ts index b79fdb2bdb..af07ea2225 100644 --- a/packages/agent/src/adapters/claude/types.ts +++ b/packages/agent/src/adapters/claude/types.ts @@ -66,9 +66,7 @@ export type Session = BaseSession & { pendingMessages: Map; nextPendingOrder: number; emitRawSDKMessages: boolean | SDKMessageFilter[]; - /** Per-source token estimates for stable pieces (system prompt, tools, etc.) - * used by the renderer's context-breakdown popover. Refreshed at session - * init and on MCP/skill changes. */ + /** Refreshed at session init and on MCP/skill changes. */ contextBreakdownBaseline?: ContextBreakdownBaseline; }; diff --git a/packages/agent/src/adapters/codex/codex-agent.ts b/packages/agent/src/adapters/codex/codex-agent.ts index d2e930b0ae..4b43b3c0f9 100644 --- a/packages/agent/src/adapters/codex/codex-agent.ts +++ b/packages/agent/src/adapters/codex/codex-agent.ts @@ -168,16 +168,8 @@ function classifyPromptError(error: unknown): unknown { ); } -/** - * Codex inlines every MCP tool schema, every skill, and a sizable preset - * system prompt — none of which are visible to us without querying the MCP - * servers ourselves. We can only attribute the bits we control (our injected - * `meta.systemPrompt`) plus a constant for codex-core's BASELINE_TOKENS - * (`codex-rs/protocol/src/protocol.rs`, the always-resident floor). Everything - * else flows into the `conversation` bucket. The breakdown is indicative - * rather than itemised on Codex; for full attribution we'd need to query - * connected MCP servers' tool lists at session start. - */ +// codex-rs/protocol/src/protocol.rs BASELINE_TOKENS — the always-resident +// floor (MCP schemas, skills, preset prompt) we can't attribute per-source. const CODEX_BASELINE_TOKENS = 12000; function buildCodexBaseline( diff --git a/packages/agent/src/adapters/codex/session-state.ts b/packages/agent/src/adapters/codex/session-state.ts index a0cec3f073..9aa8694a85 100644 --- a/packages/agent/src/adapters/codex/session-state.ts +++ b/packages/agent/src/adapters/codex/session-state.ts @@ -1,8 +1,3 @@ -/** - * Session state tracking for Codex proxy agent. - * Tracks usage accumulation, model/mode state, and config options. - */ - import type { SessionConfigOption } from "@agentclientprotocol/sdk"; import type { PermissionMode } from "../../execution-mode"; import type { ContextBreakdownBaseline } from "../claude/context-breakdown"; @@ -58,16 +53,9 @@ export function createSessionState( }; } -/** - * Reset an existing session-state object in place. Used when codex-agent - * needs to recycle the same object reference across session lifecycle calls - * (newSession/loadSession/resumeSession/forkSession) — the codex-client - * closure-captures the original reference in createCodexClient(), so - * reassigning `agent.sessionState` would orphan it and silently break - * contextUsed/contextSize/accumulatedUsage propagation. Resets all fields - * createSessionState() would set; callers reassign optional extras - * (configOptions, contextBreakdownBaseline) right after. - */ +// codex-client closure-captures the original sessionState reference, so we +// must mutate in place across newSession/loadSession/resumeSession/forkSession +// — reassigning would orphan it and silently break usage propagation. export function resetSessionState( state: CodexSessionState, sessionId: string,