diff --git a/docs/air-extensions.md b/docs/air-extensions.md index c870ed65..6e238c0e 100644 --- a/docs/air-extensions.md +++ b/docs/air-extensions.md @@ -240,6 +240,8 @@ Matching is case-insensitive and accepts multiple whitespace characters between Other active helpers use `label: "Anthropic API key"` and `detail: "API key helper"`; unknown key-source identifiers are not exposed as display text. CLI and SDK reads share this presentation, retain stale-probe protection, and suppress unchanged notifications. +For Max subscriptions, the existing `claude auth status --json` probe's optional `planTier` distinguishes `max_20x` (displayed as `Claude Ultra`) from `max_5x` (`Claude Max`). Missing, unrecognized, or malformed tiers fall back to the subscription's usual label. Other subscriptions and auth sources keep their existing classification. The raw `account.plan` and ACP `_meta.claudeCode.plan` remain unchanged. SDK account reads lack the tier, so they preserve the known label for the same account and plan; a fresh CLI read replaces it, including when the tier is absent. This is display metadata, not an entitlement check, and requires no additional probe or direct credential-file reads. + ## Zed conventions The adapter keeps these Zed and upstream conventions unchanged for every client. diff --git a/src/auth-status-reporter.ts b/src/auth-status-reporter.ts index 0919aac9..9a761e1a 100644 --- a/src/auth-status-reporter.ts +++ b/src/auth-status-reporter.ts @@ -188,8 +188,8 @@ export class AuthStatusReporter { /** * Report the identity behind a session's `AccountInfo`. * - * `AccountInfo` is richer than the CLI probe (it is what the live query - * actually authenticates with). The v1 core calls it before the + * `AccountInfo` describes what the live query authenticates with, but lacks + * the CLI's plan tier. The v1 core calls it before the * `--hide-claude-auth` guard may refuse the session or the turn: that flag * blocks the *use* of a subscription, it does not make the state secret, and * a refusal is when the client most needs to know the account it was @@ -225,7 +225,7 @@ export class AuthStatusReporter { } const fromSession = fromAccountInfo(account, apiKeyHelper); if (fromSession) { - this.set(fromSession); + this.set(mergeAuthStatus(this.current, fromSession, "session")); } else if (!this.loggedUninformativeAccount) { this.loggedUninformativeAccount = true; this.options.logger.log( @@ -337,7 +337,7 @@ export class AuthStatusReporter { // The CLI probe can be poorer than the session `AccountInfo` for the very // same login (no organization, say). Keep those extra fields rather than // regressing the payload; a different identity replaces it wholesale. - const merged = mergeAuthStatus(this.current, status); + const merged = mergeAuthStatus(this.current, status, "cli"); this.set(merged); this.options.onProbe?.(status.kind); return merged; diff --git a/src/auth-status.ts b/src/auth-status.ts index 6a0532f5..bd916578 100644 --- a/src/auth-status.ts +++ b/src/auth-status.ts @@ -144,6 +144,8 @@ export type CliAuthStatus = { orgId?: string; orgName?: string; subscriptionType?: string; + /** CLI-only tier, e.g. "max_5x" or "max_20x"; not in SDK AccountInfo. */ + planTier?: string; }; const NOT_LOGGED_IN: AuthStatus = { @@ -270,6 +272,7 @@ export function fromCliStatus(stdout: string, apiKeyHelper?: string): AuthStatus return mapAuthFields({ apiProvider: status.apiProvider, subscriptionType: status.subscriptionType, + planTier: status.planTier, apiKeySource: status.apiKeySource, email: status.email, organization: status.orgName, @@ -290,6 +293,7 @@ export function fromCliStatus(stdout: string, apiKeyHelper?: string): AuthStatus function mapAuthFields(fields: { apiProvider?: string; subscriptionType?: string; + planTier?: string; apiKeySource?: string; email?: string; organization?: string; @@ -329,7 +333,7 @@ function mapAuthFields(fields: { if (organization) account.organization = organization; return { kind: "account", - label: planLabel(subscriptionType), + label: planLabel(subscriptionType, fields.planTier), // No detail: the client falls back to `account.email` for line 2. account, }; @@ -337,12 +341,14 @@ function mapAuthFields(fields: { return notLoggedInAuthStatus(); } -/** Names the plan with exactly one "Claude". A newer CLI already reports - * "Claude Max", an older one reports "max", and both must read "Claude Max". - * Each word is title-cased; a word that already carries capitals is kept. */ -function planLabel(plan: string): string { +/** Names the plan with exactly one "Claude". Without a tier, "Claude Max" + * from a newer CLI and "max" from an older one both read "Claude Max". + * Each word is title-cased; a word that already carries capitals is kept. + * The CLI's max_20x tier identifies Ultra; other tiers keep the plan label. */ +function planLabel(plan: string, planTier?: string): string { const titled = plan.replace(/\S+/g, (word) => word.charAt(0).toUpperCase() + word.slice(1)); - return /^claude(\s|$)/i.test(plan) ? titled : `Claude ${titled}`; + const label = /^claude(\s|$)/i.test(plan) ? titled : `Claude ${titled}`; + return label.toLowerCase() === "claude max" && planTier === "max_20x" ? "Claude Ultra" : label; } /** @@ -370,8 +376,14 @@ export function sameIdentity(a: AuthStatus, b: AuthStatus): boolean { * organization the CLI probe omits. So when the read describes the same * identity, its values win field by field but the fields it lacks are kept; * when it describes a different identity, it replaces the old one wholesale. + * Session reads lack the CLI's plan tier, so keep the label for the same plan. + * A CLI read always replaces the label, including when its tier is absent. */ -export function mergeAuthStatus(previous: AuthStatus | undefined, next: AuthStatus): AuthStatus { +export function mergeAuthStatus( + previous: AuthStatus | undefined, + next: AuthStatus, + source: "cli" | "session", +): AuthStatus { if (!previous || !sameIdentity(previous, next)) { return next; } @@ -382,6 +394,14 @@ export function mergeAuthStatus(previous: AuthStatus | undefined, next: AuthStat if (previous.account || next.account) { merged.account = { ...previous.account, ...next.account }; } + if ( + source === "session" && + previous.account?.plan && + next.account?.plan && + planLabel(previous.account.plan).toLowerCase() === planLabel(next.account.plan).toLowerCase() + ) { + merged.label = previous.label; + } if (merged.vendor === undefined && previous.vendor !== undefined) { merged.vendor = previous.vendor; } diff --git a/src/tests/v1/auth-status.test.ts b/src/tests/v1/auth-status.test.ts index bb60d070..aa203e5f 100644 --- a/src/tests/v1/auth-status.test.ts +++ b/src/tests/v1/auth-status.test.ts @@ -363,6 +363,48 @@ describe("auth status mappers", () => { ).toEqual({ kind: "account", label: "Claude subscription", detail: "OAuth token" }); }); + it.each([ + ["max", "max_20x", "Claude Ultra"], + ["Claude Max", "max_20x", "Claude Ultra"], + ["max", "max_5x", "Claude Max"], + ["max", "max_other", "Claude Max"], + ["max", undefined, "Claude Max"], + ["max", "future_tier", "Claude Max"], + ["max", null, "Claude Max"], + ["max", 20, "Claude Max"], + ["max", { tier: "max_20x" }, "Claude Max"], + ["max", ["max_20x"], "Claude Max"], + ["pro", "max_20x", "Claude Pro"], + ["Future Plan", "future_tier", "Claude Future Plan"], + ])("labels CLI plan %j with tier %j as %j", (subscriptionType, planTier, label) => { + expect( + fromCliStatus( + JSON.stringify({ + ...JSON.parse(CLI_SUBSCRIPTION), + subscriptionType, + planTier, + }), + ), + ).toEqual({ + kind: "account", + label, + account: { plan: subscriptionType, email: "user@example.com", organization: "ACME" }, + }); + }); + + it.each([ + [{ apiKeySource: "env" }, { kind: "api_key", label: "Anthropic API key", detail: "env" }], + [{ apiProvider: "bedrock" }, { kind: "external", label: "AWS Bedrock" }], + [{ apiProvider: "gateway" }, { kind: "gateway", label: "Custom model gateway" }], + [{ loggedIn: false }, { kind: "none", label: "Not logged in" }], + ])("does not let a subscription tier override auth fields %j", (fields, expected) => { + expect( + fromCliStatus( + JSON.stringify({ ...JSON.parse(CLI_SUBSCRIPTION), planTier: "max_20x", ...fields }), + ), + ).toEqual(expected); + }); + it("lets a third-party backend outrank the logged-out flag", () => { // `loggedIn` tracks the claude.ai login only; on Bedrock the credentials // are external and the agent is authenticated all the same. @@ -384,19 +426,27 @@ describe("auth status mappers", () => { }; // Same login, read from a source without the organization: it survives. expect( - mergeAuthStatus(stored, { - kind: "account", - label: "Claude Max", - account: { plan: "max", email: "user@example.com" }, - }), + mergeAuthStatus( + stored, + { + kind: "account", + label: "Claude Max", + account: { plan: "max", email: "user@example.com" }, + }, + "cli", + ), ).toEqual(stored); // A different login replaces everything, organization included. expect( - mergeAuthStatus(stored, { - kind: "account", - label: "Claude Pro", - account: { plan: "pro", email: "other@example.com" }, - }), + mergeAuthStatus( + stored, + { + kind: "account", + label: "Claude Pro", + account: { plan: "pro", email: "other@example.com" }, + }, + "cli", + ), ).toEqual({ kind: "account", label: "Claude Pro", @@ -404,7 +454,11 @@ describe("auth status mappers", () => { }); // A different kind never merges, even when it looks adjacent. expect( - mergeAuthStatus(stored, { kind: "api_key", label: "Anthropic API key", detail: "env" }), + mergeAuthStatus( + stored, + { kind: "api_key", label: "Anthropic API key", detail: "env" }, + "cli", + ), ).toEqual({ kind: "api_key", label: "Anthropic API key", detail: "env" }); // Key sources identify api_key payloads. expect( @@ -414,6 +468,29 @@ describe("auth status mappers", () => { ), ).toBe(false); }); + + it.each<["cli" | "session", string, string, string | undefined, string]>([ + ["session", "max", "user@example.com", undefined, "Claude Ultra"], + ["session", "Claude Max", "user@example.com", undefined, "Claude Ultra"], + ["session", "pro", "user@example.com", undefined, "Claude Pro"], + ["session", "max", "other@example.com", undefined, "Claude Max"], + ["cli", "max", "user@example.com", "max_5x", "Claude Max"], + ["cli", "max", "user@example.com", undefined, "Claude Max"], + ["cli", "max", "user@example.com", "future_tier", "Claude Max"], + ])( + "merges a %s read of %s for %s with tier %s as %s", + (source, subscriptionType, email, planTier, label) => { + const previous = fromCliStatus( + JSON.stringify({ ...JSON.parse(CLI_SUBSCRIPTION), planTier: "max_20x" }), + ); + const fields = { apiProvider: "firstParty" as const, subscriptionType, email }; + const next = + source === "session" + ? fromAccountInfo(fields) + : fromCliStatus(JSON.stringify({ ...fields, loggedIn: true, planTier })); + expect(mergeAuthStatus(previous, next!, source).label).toBe(label); + }, + ); }); describe("auth status over ACP", () => { @@ -859,6 +936,32 @@ describe("auth status over ACP", () => { }); }); + it.each(["max_5x", undefined, "future_tier"])( + "keeps Ultra across session reads and refreshes the label for CLI tier %j", + async (planTier) => { + statusStdout = JSON.stringify({ ...JSON.parse(CLI_SUBSCRIPTION), planTier: "max_20x" }); + await initialize(); + const sessionId = await scriptedSession({ + apiProvider: "firstParty", + subscriptionType: "Claude Max", + email: "user@example.com", + }); + await promptOnce(sessionId); + expect(updates().every((call) => call[1].authStatus.label === "Claude Ultra")).toBe(true); + expect(await agent.authStatusResponse()).toMatchObject({ + accounts: [{ label: "Claude Ultra", _meta: { claudeCode: { plan: "max" } } }], + }); + await settle(); + + statusStdout = JSON.stringify({ ...JSON.parse(CLI_SUBSCRIPTION), planTier }); + await promptOnce(sessionId); + expect(lastPush()?.label).toBe("Claude Max"); + expect(authStatusUpdate.mock.calls.at(-1)?.[0]).toMatchObject({ + accounts: [{ label: "Claude Max" }], + }); + }, + ); + it("lets a probe of a different identity replace the stored one wholesale", async () => { const sessionId = await scriptedSession({ apiProvider: "firstParty", diff --git a/src/tests/v2/acp-v2.test.ts b/src/tests/v2/acp-v2.test.ts index 0d0523cf..db742996 100644 --- a/src/tests/v2/acp-v2.test.ts +++ b/src/tests/v2/acp-v2.test.ts @@ -9379,6 +9379,48 @@ describe("ACP v2 auth", () => { expect(acpAuthUpdates).toEqual([status]); }); + it.each(["max_5x", undefined, "future_tier"])( + "keeps Ultra across session reads and refreshes the label for CLI tier %j", + async (nextTier) => { + let planTier: string | undefined = "max_20x"; + execFileSpy.mockImplementation((...invocation: unknown[]) => { + const callback = invocation[invocation.length - 1] as (...a: unknown[]) => void; + callback(null, { + stdout: JSON.stringify({ ...JSON.parse(CLI_SUBSCRIPTION), planTier }), + stderr: "", + }); + }); + const account = { + apiProvider: "firstParty", + subscriptionType: "Claude Max", + email: "user@example.com", + }; + const initializationResult = sdk.initializationResult; + sdk.initializationResult = async () => ({ ...(await initializationResult()), account }); + sdk.accountInfo = async () => account; + const { app, authUpdate, acpAuthUpdates } = v2Client(); + await app.connectWith(connectRouter(), async (agent) => { + await initializeV2(agent); + expect((await authUpdate(1)).authStatus.label).toBe("Claude Ultra"); + await agent.request(v2.methods.agent.session.new, { cwd }); + expect(await agent.request(v2.methods.agent.auth.status, {})).toMatchObject({ + accounts: [{ label: "Claude Ultra" }], + }); + expect( + acpAuthUpdates.every((update) => update.accounts?.[0].label === "Claude Ultra"), + ).toBe(true); + + planTier = nextTier; + await agent.request(v2.methods.agent.auth.status, {}); + await vi.waitFor(() => + expect(acpAuthUpdates.at(-1)).toMatchObject({ + accounts: [{ label: "Claude Max", _meta: { claudeCode: { plan: "max" } } }], + }), + ); + }); + }, + ); + /** * In a remote environment with --hide-claude-auth, initialize offers the * console login as a terminal method and hides the TUI subscription login.