diff --git a/lib/accounts.ts b/lib/accounts.ts index f053bda6..2e33888c 100644 --- a/lib/accounts.ts +++ b/lib/accounts.ts @@ -1741,13 +1741,37 @@ export class AccountManager { } } +/** + * Name of the account's currently-selected workspace, if any. Lets same-email + * accounts that live in different workspaces (personal Plus vs business/team) + * stay distinguishable in `list`/`status` output. See issue #491. + */ +function activeWorkspaceName( + account: + | { workspaces?: Workspace[]; currentWorkspaceIndex?: number } + | undefined, +): string | undefined { + const workspaces = account?.workspaces; + if (!workspaces || workspaces.length === 0) return undefined; + const idx = account?.currentWorkspaceIndex ?? 0; + const workspace = workspaces[idx] ?? workspaces[0]; + return workspace?.name?.trim() || undefined; +} + export function formatAccountLabel( account: - | { email?: string; accountId?: string; accountLabel?: string } + | { + email?: string; + accountId?: string; + accountLabel?: string; + workspaces?: Workspace[]; + currentWorkspaceIndex?: number; + } | undefined, index: number, ): string { const accountLabel = account?.accountLabel?.trim(); + const workspaceName = activeWorkspaceName(account); const email = account?.email?.trim(); const accountId = account?.accountId?.trim(); const idSuffix = accountId @@ -1756,19 +1780,23 @@ export function formatAccountLabel( : accountId : null; - if (accountLabel && email && idSuffix) { - return `Account ${index + 1} (${accountLabel}, ${email}, id:${idSuffix})`; - } - if (accountLabel && email) - return `Account ${index + 1} (${accountLabel}, ${email})`; - if (accountLabel && idSuffix) - return `Account ${index + 1} (${accountLabel}, id:${idSuffix})`; - if (accountLabel) return `Account ${index + 1} (${accountLabel})`; - if (email && idSuffix) - return `Account ${index + 1} (${email}, id:${idSuffix})`; - if (email) return `Account ${index + 1} (${email})`; - if (idSuffix) return `Account ${index + 1} (${idSuffix})`; - return `Account ${index + 1}`; + const segments: string[] = []; + if (accountLabel) segments.push(accountLabel); + // Surface the active workspace so two same-email accounts in different + // workspaces remain distinguishable; skip it when it would just repeat the + // manual account label. + if (workspaceName && workspaceName !== accountLabel) { + segments.push(`[${workspaceName}]`); + } + if (email) segments.push(email); + // A bare id stands alone (e.g. "Account 1 (123456)"); once any other + // segment precedes it, prefix with "id:" for clarity. + if (idSuffix) { + segments.push(segments.length > 0 ? `id:${idSuffix}` : idSuffix); + } + + if (segments.length === 0) return `Account ${index + 1}`; + return `Account ${index + 1} (${segments.join(", ")})`; } export function formatCooldown( diff --git a/lib/schemas.ts b/lib/schemas.ts index 4c281f16..b785246d 100644 --- a/lib/schemas.ts +++ b/lib/schemas.ts @@ -146,6 +146,22 @@ export const RateLimitStateV3Schema = z.record( export type RateLimitStateV3FromSchema = z.infer; +/** + * Workspace entry within an account. Supports a single OpenAI/Google account + * that belongs to multiple ChatGPT workspaces (e.g. personal Plus + a + * business/team workspace), each a distinct quota pool keyed by its org_id + * (`id`). Mirrors the `Workspace` TS interface in `lib/accounts.ts`. See #491. + */ +export const WorkspaceSchema = z.object({ + id: z.string(), + name: z.string().optional(), + enabled: z.boolean(), + disabledAt: z.number().optional(), + isDefault: z.boolean().optional(), +}); + +export type WorkspaceFromSchema = z.infer; + /** * Account metadata V3 - current storage format. */ @@ -164,6 +180,11 @@ export const AccountMetadataV3Schema = z.object({ rateLimitResetTimes: RateLimitStateV3Schema.optional(), coolingDownUntil: z.number().optional(), cooldownReason: CooldownReasonSchema.optional(), + // Multi-workspace support (#491): without these here, the strict z.object + // strips workspace tracking on every load, so login-captured workspaces + // silently vanish after one read/write round-trip. + workspaces: z.array(WorkspaceSchema).optional(), + currentWorkspaceIndex: z.number().optional(), }); export type AccountMetadataV3FromSchema = z.infer< diff --git a/test/accounts.test.ts b/test/accounts.test.ts index 4753a7fc..3c050a0a 100644 --- a/test/accounts.test.ts +++ b/test/accounts.test.ts @@ -776,6 +776,78 @@ describe("AccountManager", () => { ); }); + it("surfaces the active workspace to distinguish same-email accounts (#491)", () => { + const personal = { + email: "user@gmail.com", + accountId: "org-AAAA", + workspaces: [{ id: "org-AAAA", name: "Personal Plus", enabled: true }], + currentWorkspaceIndex: 0, + }; + const business = { + email: "user@gmail.com", + accountId: "org-BBBB", + workspaces: [{ id: "org-BBBB", name: "GkTech Business", enabled: true }], + currentWorkspaceIndex: 0, + }; + expect(formatAccountLabel(personal, 0)).toBe( + "Account 1 ([Personal Plus], user@gmail.com, id:g-AAAA)", + ); + expect(formatAccountLabel(business, 1)).toBe( + "Account 2 ([GkTech Business], user@gmail.com, id:g-BBBB)", + ); + }); + + it("follows currentWorkspaceIndex when picking the workspace tag (#491)", () => { + expect( + formatAccountLabel( + { + email: "user@gmail.com", + workspaces: [ + { id: "org-AAAA", name: "Personal Plus", enabled: true }, + { id: "org-BBBB", name: "GkTech Business", enabled: true }, + ], + currentWorkspaceIndex: 1, + }, + 0, + ), + ).toBe("Account 1 ([GkTech Business], user@gmail.com)"); + }); + + it("omits the workspace tag when it duplicates the account label (#491)", () => { + expect( + formatAccountLabel( + { + accountLabel: "Personal Plus", + email: "user@gmail.com", + workspaces: [ + { id: "org-AAAA", name: "Personal Plus", enabled: true }, + ], + currentWorkspaceIndex: 0, + }, + 0, + ), + ).toBe("Account 1 (Personal Plus, user@gmail.com)"); + }); + + it("ignores empty or unnamed workspaces in the label (#491)", () => { + expect( + formatAccountLabel( + { + email: "user@gmail.com", + workspaces: [{ id: "org-AAAA", enabled: true }], + currentWorkspaceIndex: 0, + }, + 0, + ), + ).toBe("Account 1 (user@gmail.com)"); + expect( + formatAccountLabel( + { email: "user@gmail.com", workspaces: [], currentWorkspaceIndex: 0 }, + 0, + ), + ).toBe("Account 1 (user@gmail.com)"); + }); + it("performs true round-robin rotation across multiple requests", () => { const now = Date.now(); const stored = { diff --git a/test/schemas.test.ts b/test/schemas.test.ts index e26ca4dc..b43f4912 100644 --- a/test/schemas.test.ts +++ b/test/schemas.test.ts @@ -232,6 +232,43 @@ describe("AccountMetadataV3Schema", () => { }); expect(result.success).toBe(false); }); + + it("preserves workspaces and currentWorkspaceIndex (#491)", () => { + const account = { + ...validAccount, + workspaces: [ + { + id: "org-AAAA", + name: "Personal Plus", + enabled: true, + isDefault: true, + }, + { + id: "org-BBBB", + name: "GkTech Business", + enabled: false, + disabledAt: 123, + }, + ], + currentWorkspaceIndex: 1, + }; + const result = AccountMetadataV3Schema.safeParse(account); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.workspaces).toHaveLength(2); + expect(result.data.workspaces?.[0]?.name).toBe("Personal Plus"); + expect(result.data.workspaces?.[1]?.enabled).toBe(false); + expect(result.data.currentWorkspaceIndex).toBe(1); + } + }); + + it("rejects a workspace missing its id (#491)", () => { + const result = AccountMetadataV3Schema.safeParse({ + ...validAccount, + workspaces: [{ name: "Personal Plus", enabled: true }], + }); + expect(result.success).toBe(false); + }); }); describe("AccountStorageV3Schema", () => { @@ -779,6 +816,37 @@ describe("safeParseJson", () => { expect(result).not.toBeNull(); expect(result?.version).toBe(3); }); + + it("keeps workspaces through the load round-trip (#491)", () => { + // Regression: the strict z.object used to strip workspace tracking on + // every load, so login-captured workspaces vanished after one reload. + const raw = JSON.stringify({ + version: 3, + accounts: [ + { + refreshToken: "rt", + addedAt: 1, + lastUsed: 1, + accountId: "org-AAAA", + email: "user@gmail.com", + workspaces: [{ id: "org-AAAA", name: "Personal Plus", enabled: true }], + currentWorkspaceIndex: 0, + }, + ], + activeIndex: 0, + }); + const result = safeParseJson(raw, AnyAccountStorageSchema, "test.workspaces"); + expect(result).not.toBeNull(); + const account = result?.accounts?.[0] as + | { + workspaces?: Array<{ name?: string }>; + currentWorkspaceIndex?: number; + } + | undefined; + expect(account?.workspaces).toHaveLength(1); + expect(account?.workspaces?.[0]?.name).toBe("Personal Plus"); + expect(account?.currentWorkspaceIndex).toBe(0); + }); }); describe("safeParseFlaggedAccountStorageV1", () => {