From e5c3e3419781a97c5ce95cd6ed150c9bb4cef8e9 Mon Sep 17 00:00:00 2001 From: luvs01 Date: Fri, 18 Sep 2026 09:13:49 +0900 Subject: [PATCH] fix(cli): preserve absent OAuth plan fields An absent plan key means the proxy predates tier reporting while plan: null means it checked and found no tier. The CLI projection collapsed absent to null, erasing the distinction. Forward the key only when the response owns it. --- src/cli/account-api.ts | 7 +++--- tests/cli/cli-dto-fidelity.test.ts | 40 ++++++++++++++++++++++++++++++ 2 files changed, 44 insertions(+), 3 deletions(-) diff --git a/src/cli/account-api.ts b/src/cli/account-api.ts index 7e7cda8e3b..9b8cf7108a 100644 --- a/src/cli/account-api.ts +++ b/src/cli/account-api.ts @@ -370,9 +370,10 @@ async function fetchOAuthRows( email: a.email, active: a.active ?? a.id === activeId, needsReauth: a.needsReauth, - // Forward the server's answer verbatim, including `null`. Collapsing null to "absent" here - // would destroy the one distinction this field exists to make. - plan: a.plan ?? null, + // Forward the server's answer verbatim. An absent key means the proxy predates tier + // reporting while `null` means it checked and found no tier — collapsing either + // direction would destroy the one distinction this field exists to make. + ...(Object.hasOwn(a, "plan") ? { plan: a.plan } : {}), ...(a.quota !== undefined ? { quota: a.quota } : {}), ...(a.quotaUnavailable !== undefined ? { quotaUnavailable: a.quotaUnavailable } : {}), ...(a.quotaUnavailable === true && parseQuotaFailureCode(a.quotaFailure) diff --git a/tests/cli/cli-dto-fidelity.test.ts b/tests/cli/cli-dto-fidelity.test.ts index 7b5d22d8fa..67cc148795 100644 --- a/tests/cli/cli-dto-fidelity.test.ts +++ b/tests/cli/cli-dto-fidelity.test.ts @@ -4,6 +4,7 @@ import { formatUsageReport } from "../../src/cli/usage-report"; /** formatUsageReport returns lines; assertions here are about rendered text. */ const joinReport = (input: Parameters[0]): string => formatUsageReport(input).join("\n"); import { formatAccountTable, type AccountRowForTest } from "../../src/cli/account"; +import { fetchRows, type AccountDeps } from "../../src/cli/account-api"; /** * #2700, #2703: the CLI discarded fields the API already returned. @@ -259,3 +260,42 @@ describe("#2705 access key usage columns", () => { expect(await listOutput({ keys: [] })).toContain("No API access keys configured."); }); }); + +/** + * The OAuth account DTO declares `plan` optional because older proxies never sent it. + * An absent key means the proxy predates tier reporting while `plan: null` means the + * proxy checked and found no tier -- the same silently-wrong-output class of defect as + * the fields above, one layer earlier: the wire value was fine and the projection + * rewrote it. + */ +describe("OAuth plan field preserves the wire presence signal", () => { + const deps = (accounts: Array>): AccountDeps => ({ + baseUrl: "http://127.0.0.1:10100", + fetchImpl: (async () => + Response.json({ activeAccountId: null, accounts })) as unknown as typeof fetch, + }); + + test("an absent plan key stays absent instead of being synthesized as null", async () => { + const { rows } = await fetchRows( + deps([{ id: "legacy" }]), + "http://127.0.0.1:10100", + "anthropic", + "oauth", + ); + expect(rows[0]).not.toHaveProperty("plan"); + }); + + test("an explicit null and a reported tier both reach the row verbatim", async () => { + const { rows } = await fetchRows( + deps([ + { id: "unknown", plan: null }, + { id: "known", plan: "max" }, + ]), + "http://127.0.0.1:10100", + "anthropic", + "oauth", + ); + expect(rows[0]).toHaveProperty("plan", null); + expect(rows[1]).toHaveProperty("plan", "max"); + }); +});