diff --git a/packages/agents-usage/src/collectors/cursor.test.ts b/packages/agents-usage/src/collectors/cursor.test.ts index f92ea8a0d..0a191c2ec 100644 --- a/packages/agents-usage/src/collectors/cursor.test.ts +++ b/packages/agents-usage/src/collectors/cursor.test.ts @@ -73,6 +73,7 @@ describe("parseCursorUsage", () => { expect(snap.windows).toHaveLength(2); // on-demand disabled → no extra window const auto = snap.windows.find((w) => w.id === "cursor-auto")!; + expect(auto.label).toBe("Cursor Models"); expect(auto.usedPercent).toBe(34); expect(auto.resetsAt).toBe(1_719_600_000_000); const api = snap.windows.find((w) => w.id === "cursor-api")!; diff --git a/packages/agents-usage/src/collectors/cursor.ts b/packages/agents-usage/src/collectors/cursor.ts index 87c0cace7..eea636e15 100644 --- a/packages/agents-usage/src/collectors/cursor.ts +++ b/packages/agents-usage/src/collectors/cursor.ts @@ -12,8 +12,9 @@ import type { UsageSnapshot, UsageWindow } from "../types"; * Schema (per codexbar) of GET /api/usage-summary → individualUsage.plan: * { used (cents), limit (cents), breakdown { included, bonus, total }, * totalPercentUsed, autoPercentUsed, apiPercentUsed } + billingCycleEnd + - * membershipType. Surfaced as Auto and API windows. API dollars use real - * spend over the vendor plan limit; the bar uses apiPercentUsed separately. + * membershipType. Surfaced as Cursor Models (`autoPercentUsed`) and API + * windows. API dollars use real spend over the vendor plan limit; the bar + * uses apiPercentUsed separately. */ export const CURSOR_USAGE_ENDPOINT = "https://cursor.com/api/usage-summary"; @@ -134,7 +135,7 @@ export function parseCursorUsage( if (autoPercent !== undefined) { windows.push({ id: "cursor-auto", - label: "Auto + Composer", + label: "Cursor Models", usedPercent: autoPercent, unit: "percent", ...withReset, diff --git a/packages/agents-usage/src/formatters.test.ts b/packages/agents-usage/src/formatters.test.ts index 8c9f6a69c..99363d7b1 100644 --- a/packages/agents-usage/src/formatters.test.ts +++ b/packages/agents-usage/src/formatters.test.ts @@ -21,6 +21,10 @@ describe("usageWindowDisplayLabel", () => { expect(usageWindowDisplayLabel({ id: "weekly-fable", label: "x", usedPercent: 0 })).toBe( "Weekly · Fable", ); + expect(usageWindowDisplayLabel({ id: "cursor-auto", label: "x", usedPercent: 0 })).toBe( + "Cursor Models", + ); + expect(usageWindowDisplayLabel({ id: "cursor-api", label: "x", usedPercent: 0 })).toBe("API"); }); it("honors a collector's custom monthly label (e.g. z.ai 'MCP')", () => { diff --git a/packages/agents-usage/src/formatters.ts b/packages/agents-usage/src/formatters.ts index 10b775408..eb1476c95 100644 --- a/packages/agents-usage/src/formatters.ts +++ b/packages/agents-usage/src/formatters.ts @@ -50,7 +50,7 @@ const KNOWN_WINDOW_LABELS: Record = { "weekly-fable": "Weekly · Fable", monthly: "Monthly", "extra-usage": "Extra usage", - "cursor-auto": "Auto + Composer", + "cursor-auto": "Cursor Models", "cursor-api": "API", }; diff --git a/src/renderer/components/providers/ProviderUsageCircle.tsx b/src/renderer/components/providers/ProviderUsageCircle.tsx index 877af5e50..edbc7e089 100644 --- a/src/renderer/components/providers/ProviderUsageCircle.tsx +++ b/src/renderer/components/providers/ProviderUsageCircle.tsx @@ -8,13 +8,14 @@ import { usageToneColor } from "./usageTone"; * split (Claude/Codex: a 5h session plus a weekly window) render TWO concentric * rings — like a clock's hands, the faster session is the OUTER ring and the * slower weekly/monthly is the INNER ring, so a full inner ring flags "weekly - * almost gone" even when the session is idle. Cursor renders Auto + Composer - * outside and API inside. Antigravity shows one of its two quota groups (Gemini - * vs Claude+GPT), selected via `ringGroup`. Every other provider renders a - * SINGLE ring on its most-constrained window — an at-a-glance "closest to the - * limit" read. Which windows map to which ring is a per-provider descriptor in - * `usageProviders.ts` (see {@link pickUsageRings}). Each ring is colored by its - * own tone. Reuses the ring math from ThreadContextIndicator. + * almost gone" even when the session is idle. Cursor renders Cursor Models + * outside and API inside. Antigravity shows one of its two quota groups + * (Gemini vs Claude+GPT), selected via `ringGroup`. Every other provider + * renders a SINGLE ring on its most-constrained window — an at-a-glance + * "closest to the limit" read. Which windows map to which ring is a + * per-provider descriptor in `usageProviders.ts` (see {@link pickUsageRings}). + * Each ring is colored by its own tone. Reuses the ring math from + * ThreadContextIndicator. */ function Ring(props: { window: UsageWindow; radius: number }) { diff --git a/src/renderer/state/agentStatusesStore.test.ts b/src/renderer/state/agentStatusesStore.test.ts index 92638f924..0537044ab 100644 --- a/src/renderer/state/agentStatusesStore.test.ts +++ b/src/renderer/state/agentStatusesStore.test.ts @@ -47,10 +47,7 @@ beforeEach(reset); describe("persisted agent status cache", () => { it("invalidates v10 statuses whose terminal auth methods lack baseSpawnEnv-derived env", async () => { const options = useAgentStatusesStore.persist.getOptions(); - expect(options.version).toBe(14); - // Mirrors supervisor STATUS_CACHE_VERSION=16: a persisted antigravity - // status from before the derivation would build the `agy` login command - // without `AGY_CLI_DISABLE_AUTO_UPDATE`. + expect(options.version).toBe(15); const staleLogin = makeStatus({ kind: "antigravity", label: "Antigravity", @@ -75,9 +72,42 @@ describe("persisted agent status cache", () => { }); }); + it("invalidates v14 statuses that grouped Cursor Grok under Other models", async () => { + const options = useAgentStatusesStore.persist.getOptions(); + expect(options.version).toBe(15); + const staleCursor = makeStatus({ + kind: "cursor", + label: "Cursor", + capabilities: { + ...makeStatus().capabilities, + models: [{ id: "grok-4.6", label: "Cursor Grok 4.6" }], + subProviders: [ + { id: "cursor", label: "Cursor Models" }, + { id: "other", label: "Other models" }, + ], + modelSubProvider: { "grok-4.6": "other" }, + }, + }); + const migrated = await options.migrate!( + { + agentStatuses: [staleCursor], + wslAgentStatuses: [], + windowsLoaded: true, + wslLoaded: true, + }, + 14, + ); + expect(migrated).toMatchObject({ + agentStatuses: [], + wslAgentStatuses: [], + windowsLoaded: false, + wslLoaded: false, + }); + }); + it("invalidates v8 statuses cached before successful ACP sessions established auth", async () => { const options = useAgentStatusesStore.persist.getOptions(); - expect(options.version).toBe(14); + expect(options.version).toBe(15); const staleAcp = makeStatus({ kind: "acp-generic:example", label: "Example ACP", @@ -104,7 +134,7 @@ describe("persisted agent status cache", () => { it("invalidates v6 statuses produced without the Grok login-shell environment", async () => { const options = useAgentStatusesStore.persist.getOptions(); - expect(options.version).toBe(14); + expect(options.version).toBe(15); expect(options.migrate).toBeTypeOf("function"); const grok = makeStatus({ diff --git a/src/renderer/state/agentStatusesStore.ts b/src/renderer/state/agentStatusesStore.ts index 5d54fa139..6a0414655 100644 --- a/src/renderer/state/agentStatusesStore.ts +++ b/src/renderer/state/agentStatusesStore.ts @@ -263,12 +263,9 @@ export const useAgentStatusesStore = create()( }), { name: "poracode-agent-statuses-v1", - version: 14, - // v14 mirrors the supervisor STATUS_CACHE_VERSION=17 bump: Cursor SDK - // runtime variants now carry their own account email. v13 covered - // Cursor profiles becoming SDK-only. This mirrors the supervisor cache - // invalidation, which only covers the supervisor's on-disk cache, not - // this localStorage copy. + version: 15, + // v15 mirrors supervisor STATUS_CACHE_VERSION=18 so this localStorage + // copy does not keep Cursor Grok grouped under Other models. migrate: (persisted) => { const prev = (persisted ?? {}) as Partial; return { diff --git a/src/supervisor/agents/cursor/modelGrouping.test.ts b/src/supervisor/agents/cursor/modelGrouping.test.ts new file mode 100644 index 000000000..b67db4f2a --- /dev/null +++ b/src/supervisor/agents/cursor/modelGrouping.test.ts @@ -0,0 +1,51 @@ +import { describe, expect, it } from "vitest"; +import { cursorModelGroup, cursorModelGrouping } from "./modelGrouping"; + +describe("cursorModelGroup", () => { + it.each([ + ["auto", "cursor"], + ["default", "cursor"], + ["auto-smart", "cursor"], + ["auto-smart[optimize_for=intelligence]", "cursor"], + ["composer-2.5", "cursor"], + ["composer-2.5-fast", "cursor"], + ["composer-2.5[effort=high,fast=true]", "cursor"], + ["grok-4.6", "cursor"], + ["grok-4.5", "cursor"], + ["grok-4.6-fast", "cursor"], + ["grok-4.6[effort=high,fast=true]", "cursor"], + ["composer-3", "cursor"], + ["gpt-5.6-sol", "other"], + ["gpt-5.6-luna", "other"], + ["claude-opus-5", "other"], + ["opus-5", "other"], + ["sonnet-5", "other"], + ["gemini-3.7-flash", "other"], + ["kimi-k3", "other"], + ["glm-5.1", "other"], + ] as const)("%s → %s", (modelId, group) => { + expect(cursorModelGroup(modelId)).toBe(group); + }); +}); + +describe("cursorModelGrouping", () => { + it("keeps unknown Cursor ids in Cursor Models and third-party vendors in Other", () => { + expect( + cursorModelGrouping([ + { id: "composer-2.5", label: "Composer 2.5" }, + { id: "grok-4.6", label: "Cursor Grok 4.6" }, + { id: "gpt-5.6-luna", label: "GPT-5.6 Luna" }, + ]), + ).toEqual({ + subProviders: [ + { id: "cursor", label: "Cursor Models" }, + { id: "other", label: "Other models" }, + ], + modelSubProvider: { + "composer-2.5": "cursor", + "grok-4.6": "cursor", + "gpt-5.6-luna": "other", + }, + }); + }); +}); diff --git a/src/supervisor/agents/cursor/modelGrouping.ts b/src/supervisor/agents/cursor/modelGrouping.ts index 622763c45..5f229f81b 100644 --- a/src/supervisor/agents/cursor/modelGrouping.ts +++ b/src/supervisor/agents/cursor/modelGrouping.ts @@ -2,20 +2,19 @@ import type { AgentCapability, LabeledOption } from "@/shared/contracts"; import { parseCursorModelId } from "@/shared/cursorModelId"; /** - * Sub-provider grouping shared by every Cursor model picker projection (CLI - * terminal models, ACP models, and the SDK catalog). Kept in its own module so - * the SDK projection doesn't have to import the detection module's process - * probes just to classify a model id. + * Sub-provider grouping for CLI, ACP, and SDK picker projections. Cursor's + * catalog has no usage-pool field: known third-party vendor prefixes are Other + * models; remaining ids are the first-party Cursor Models pool. */ export const CURSOR_MODEL_GROUP_ID = "cursor"; export const OTHER_MODEL_GROUP_ID = "other"; -const CURSOR_FIRST_PARTY_MODEL_RE = /^(?:default$|auto(?:-smart)?|composer(?:-|$))/iu; +const CURSOR_THIRD_PARTY_MODEL_RE = /^(?:gpt|claude|gemini|kimi|glm|opus|sonnet|haiku)(?:-|$)/iu; export function cursorModelGroup(modelId: string): "cursor" | "other" { const baseId = parseCursorModelId(modelId).baseId; - return CURSOR_FIRST_PARTY_MODEL_RE.test(baseId) ? CURSOR_MODEL_GROUP_ID : OTHER_MODEL_GROUP_ID; + return CURSOR_THIRD_PARTY_MODEL_RE.test(baseId) ? OTHER_MODEL_GROUP_ID : CURSOR_MODEL_GROUP_ID; } export function cursorModelGrouping( diff --git a/src/supervisor/agents/cursor/sdkModels.test.ts b/src/supervisor/agents/cursor/sdkModels.test.ts index 4acd07251..134a9c4c1 100644 --- a/src/supervisor/agents/cursor/sdkModels.test.ts +++ b/src/supervisor/agents/cursor/sdkModels.test.ts @@ -255,6 +255,8 @@ describe("cursorSdkCapabilitiesFromModels", () => { expect(capabilities.models.map(({ id }) => id)).toEqual(["default", "opus-5", "composer-2.5"]); expect(capabilities.modelSubProvider?.default).toBe("cursor"); + expect(capabilities.modelSubProvider?.["composer-2.5"]).toBe("cursor"); + expect(capabilities.modelSubProvider?.["opus-5"]).toBe("other"); expect( buildCursorSdkModelSelection({ model: "auto" }, [ { id: "opus-5", displayName: "Opus 5" }, @@ -263,6 +265,24 @@ describe("cursorSdkCapabilitiesFromModels", () => { ).toEqual({ id: "default" }); }); + it("groups first-party Grok with Cursor Models, not the API Other Models pool", () => { + const capabilities = cursorSdkCapabilitiesFromModels([ + { id: "composer-2.5", displayName: "Composer 2.5" }, + { id: "grok-4.6", displayName: "Cursor Grok 4.6" }, + { id: "grok-4.5", displayName: "Cursor Grok 4.5" }, + { id: "gpt-5.6-luna", displayName: "GPT-5.6 Luna" }, + { id: "gemini-3.7-flash", displayName: "Gemini 3.7 Flash" }, + ]); + + expect(capabilities.modelSubProvider).toEqual({ + "composer-2.5": "cursor", + "grok-4.6": "cursor", + "grok-4.5": "cursor", + "gpt-5.6-luna": "other", + "gemini-3.7-flash": "other", + }); + }); + it("removes the effort suffix while keeping Reasoning, Context, and Fast controls", () => { const currentCatalog = [ { diff --git a/src/supervisor/runtime/agentStatusCache.test.ts b/src/supervisor/runtime/agentStatusCache.test.ts index 88abe720e..213d0eebf 100644 --- a/src/supervisor/runtime/agentStatusCache.test.ts +++ b/src/supervisor/runtime/agentStatusCache.test.ts @@ -191,6 +191,46 @@ describe("agent status cache", () => { expect(cached).toEqual({ windows: [], wsl: [], fromCache: false }); }); + it("invalidates v17 caches that grouped Cursor Grok under Other models", () => { + const dataDir = makeTempDir(); + process.env.PORACODE_DATA_DIR = dataDir; + + const { cacheDir, statusCachePath } = resolvePoracodePaths(dataDir); + mkdirSync(cacheDir, { recursive: true }); + writeFileSync( + statusCachePath, + JSON.stringify({ + version: 17, + windows: [ + { + kind: "cursor", + label: "Cursor", + installed: true, + authState: "authenticated", + capabilities: { + models: [{ id: "grok-4.6", label: "Cursor Grok 4.6" }], + modelSubProvider: { "grok-4.6": "other" }, + }, + }, + ], + }), + ); + + const runtime = makeRuntime(() => {}); + const cached = ( + runtime.agentStatusService as unknown as { + readCachedStatuses: (wslDistros: readonly string[]) => { + windows: AgentStatus[]; + wsl: AgentStatus[]; + fromCache: boolean; + }; + } + ).readCachedStatuses([]); + + expect(STATUS_CACHE_VERSION).toBe(18); + expect(cached).toEqual({ windows: [], wsl: [], fromCache: false }); + }); + it("migrates stale cached settingDefs to current schema", () => { const dataDir = makeTempDir(); process.env.PORACODE_DATA_DIR = dataDir; diff --git a/src/supervisor/runtime/agentStatusService.ts b/src/supervisor/runtime/agentStatusService.ts index 8f43584b6..66311c54e 100644 --- a/src/supervisor/runtime/agentStatusService.ts +++ b/src/supervisor/runtime/agentStatusService.ts @@ -66,8 +66,11 @@ const execFileAsync = promisify(execFile); * statuses that advertised profile CLI login/ACP variants must be re-probed. * v17 adds per-runtime `providerMetadata` so Cursor SDK can show the API-key * account email without overwriting the CLI login identity. + * v18 regroups Cursor first-party models (Grok, Composer, future Cursor ids) + * into the Cursor Models pool by denylisting known third-party vendor prefixes + * instead of allowlisting first-party families. */ -export const STATUS_CACHE_VERSION = 17; +export const STATUS_CACHE_VERSION = 18; const WSL_AGENT_DETECTION_TIMEOUT_MS = 60_000; const WSL_LXSS_REGISTRY_KEY = "HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Lxss"; diff --git a/src/supervisor/runtime/usageService.test.ts b/src/supervisor/runtime/usageService.test.ts index 0ca464e9c..999e3c83e 100644 --- a/src/supervisor/runtime/usageService.test.ts +++ b/src/supervisor/runtime/usageService.test.ts @@ -66,6 +66,34 @@ afterEach(() => { }); describe("UsageService", () => { + it("discards v2 caches that still label the first-party window Auto + Composer", async () => { + const cachePath = tempCachePath(); + writeFileSync( + cachePath, + JSON.stringify({ + version: 2, + snapshots: [ + { + providerId: "cursor", + status: "ok", + windows: [{ id: "cursor-auto", label: "Auto + Composer", usedPercent: 34 }], + fetchedAt: NOW, + }, + ], + }), + ); + const service = new UsageService({ + emit: () => {}, + cachePath, + host: makeHost({}), + localCollectors: stubLocalCollectors(), + }); + + const result = await service.getProviderUsage({ providerIds: ["cursor"] }); + expect(result.fromCache).toBe(false); + expect(result.snapshots).toEqual([]); + }); + it("refresh defaults to Claude and Codex only, emits per-provider then a terminal event", async () => { const events: SupervisorEvent[] = []; const service = new UsageService({ diff --git a/src/supervisor/runtime/usageService.ts b/src/supervisor/runtime/usageService.ts index bf7a31b10..ef95ad872 100644 --- a/src/supervisor/runtime/usageService.ts +++ b/src/supervisor/runtime/usageService.ts @@ -36,8 +36,11 @@ import { readSupervisorSharedSettings } from "./supervisorSharedSettings"; * 5 min, 2 min floor) — never a fast poll. */ -/** Bump when the cached snapshot shape changes so stale caches are discarded. */ -const USAGE_CACHE_VERSION = 2; +/** + * Bump when the cached snapshot shape changes so stale caches are discarded. + * v3 relabels Cursor's first-party window from Auto + Composer to Cursor Models. + */ +const USAGE_CACHE_VERSION = 3; /** The full default provider set, from the package catalog (single source of truth). */ const DEFAULT_PROVIDER_IDS: readonly string[] = allUsageProviderDescriptors().map((d) => d.id); const MIN_REFRESH_INTERVAL_MS = 2 * 60_000;