@vacbo/opencode-anthropic-fix 0.1.7 → 0.1.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (107) hide show
  1. package/README.md +88 -88
  2. package/dist/opencode-anthropic-auth-cli.mjs +804 -507
  3. package/dist/opencode-anthropic-auth-plugin.js +4751 -4109
  4. package/package.json +67 -59
  5. package/src/__tests__/billing-edge-cases.test.ts +59 -59
  6. package/src/__tests__/bun-proxy.parallel.test.ts +388 -382
  7. package/src/__tests__/cc-comparison.test.ts +87 -87
  8. package/src/__tests__/cc-credentials.test.ts +254 -250
  9. package/src/__tests__/cch-drift-checker.test.ts +51 -51
  10. package/src/__tests__/cch-native-style.test.ts +56 -56
  11. package/src/__tests__/debug-gating.test.ts +42 -42
  12. package/src/__tests__/decomposition-smoke.test.ts +68 -68
  13. package/src/__tests__/fingerprint-regression.test.ts +575 -566
  14. package/src/__tests__/helpers/conversation-history.smoke.test.ts +271 -271
  15. package/src/__tests__/helpers/conversation-history.ts +119 -119
  16. package/src/__tests__/helpers/deferred.smoke.test.ts +103 -103
  17. package/src/__tests__/helpers/deferred.ts +69 -69
  18. package/src/__tests__/helpers/in-memory-storage.smoke.test.ts +155 -155
  19. package/src/__tests__/helpers/in-memory-storage.ts +88 -88
  20. package/src/__tests__/helpers/mock-bun-proxy.smoke.test.ts +68 -68
  21. package/src/__tests__/helpers/mock-bun-proxy.ts +189 -189
  22. package/src/__tests__/helpers/plugin-fetch-harness.smoke.test.ts +273 -273
  23. package/src/__tests__/helpers/plugin-fetch-harness.ts +288 -288
  24. package/src/__tests__/helpers/sse.smoke.test.ts +236 -236
  25. package/src/__tests__/helpers/sse.ts +209 -209
  26. package/src/__tests__/index.parallel.test.ts +605 -595
  27. package/src/__tests__/sanitization-regex.test.ts +112 -112
  28. package/src/__tests__/state-bounds.test.ts +90 -90
  29. package/src/account-identity.test.ts +197 -192
  30. package/src/account-identity.ts +69 -67
  31. package/src/account-state.test.ts +86 -86
  32. package/src/account-state.ts +25 -25
  33. package/src/accounts/matching.test.ts +335 -0
  34. package/src/accounts/matching.ts +167 -0
  35. package/src/accounts/persistence.test.ts +345 -0
  36. package/src/accounts/persistence.ts +432 -0
  37. package/src/accounts/repair.test.ts +276 -0
  38. package/src/accounts/repair.ts +407 -0
  39. package/src/accounts.dedup.test.ts +621 -621
  40. package/src/accounts.test.ts +933 -929
  41. package/src/accounts.ts +633 -989
  42. package/src/backoff.test.ts +345 -345
  43. package/src/backoff.ts +219 -219
  44. package/src/betas.ts +124 -124
  45. package/src/bun-fetch.test.ts +345 -342
  46. package/src/bun-fetch.ts +424 -424
  47. package/src/bun-proxy.test.ts +25 -25
  48. package/src/bun-proxy.ts +209 -209
  49. package/src/cc-credentials.ts +111 -111
  50. package/src/circuit-breaker.test.ts +184 -184
  51. package/src/circuit-breaker.ts +169 -169
  52. package/src/cli/commands/auth.ts +963 -0
  53. package/src/cli/commands/config.ts +547 -0
  54. package/src/cli/formatting.test.ts +406 -0
  55. package/src/cli/formatting.ts +219 -0
  56. package/src/cli.ts +255 -2022
  57. package/src/commands/handlers/betas.ts +100 -0
  58. package/src/commands/handlers/config.ts +99 -0
  59. package/src/commands/handlers/files.ts +375 -0
  60. package/src/commands/oauth-flow.ts +181 -166
  61. package/src/commands/prompts.ts +61 -61
  62. package/src/commands/router.test.ts +421 -0
  63. package/src/commands/router.ts +143 -635
  64. package/src/config.test.ts +482 -482
  65. package/src/config.ts +412 -404
  66. package/src/constants.ts +48 -48
  67. package/src/drift/cch-constants.ts +95 -95
  68. package/src/env.ts +111 -105
  69. package/src/headers/billing.ts +33 -33
  70. package/src/headers/builder.ts +130 -130
  71. package/src/headers/cch.ts +75 -75
  72. package/src/headers/stainless.ts +25 -25
  73. package/src/headers/user-agent.ts +23 -23
  74. package/src/index.ts +436 -828
  75. package/src/models.ts +27 -27
  76. package/src/oauth.test.ts +102 -102
  77. package/src/oauth.ts +178 -178
  78. package/src/parent-pid-watcher.test.ts +148 -148
  79. package/src/parent-pid-watcher.ts +69 -69
  80. package/src/plugin-helpers.ts +82 -82
  81. package/src/refresh-helpers.ts +145 -139
  82. package/src/refresh-lock.test.ts +94 -94
  83. package/src/refresh-lock.ts +93 -93
  84. package/src/request/body.history.test.ts +579 -571
  85. package/src/request/body.ts +255 -255
  86. package/src/request/metadata.ts +65 -65
  87. package/src/request/retry.test.ts +156 -156
  88. package/src/request/retry.ts +67 -67
  89. package/src/request/url.ts +21 -21
  90. package/src/request-orchestration-helpers.ts +648 -0
  91. package/src/response/index.ts +5 -5
  92. package/src/response/mcp.ts +58 -58
  93. package/src/response/streaming.test.ts +313 -311
  94. package/src/response/streaming.ts +412 -410
  95. package/src/rotation.test.ts +304 -301
  96. package/src/rotation.ts +205 -205
  97. package/src/storage.test.ts +547 -547
  98. package/src/storage.ts +315 -291
  99. package/src/system-prompt/builder.ts +38 -38
  100. package/src/system-prompt/index.ts +5 -5
  101. package/src/system-prompt/normalize.ts +60 -60
  102. package/src/system-prompt/sanitize.ts +30 -30
  103. package/src/thinking.ts +21 -20
  104. package/src/token-refresh.test.ts +265 -265
  105. package/src/token-refresh.ts +219 -214
  106. package/src/types.ts +30 -30
  107. package/dist/bun-proxy.mjs +0 -291
@@ -3,20 +3,20 @@ import { AccountManager } from "./accounts.js";
3
3
  import { DEFAULT_CONFIG } from "./config.js";
4
4
 
5
5
  vi.mock("./storage.js", () => ({
6
- createDefaultStats: (now?: number) => ({
7
- requests: 0,
8
- inputTokens: 0,
9
- outputTokens: 0,
10
- cacheReadTokens: 0,
11
- cacheWriteTokens: 0,
12
- lastReset: now ?? Date.now(),
13
- }),
14
- loadAccounts: vi.fn(),
15
- saveAccounts: vi.fn().mockResolvedValue(undefined),
6
+ createDefaultStats: (now?: number) => ({
7
+ requests: 0,
8
+ inputTokens: 0,
9
+ outputTokens: 0,
10
+ cacheReadTokens: 0,
11
+ cacheWriteTokens: 0,
12
+ lastReset: now ?? Date.now(),
13
+ }),
14
+ loadAccounts: vi.fn(),
15
+ saveAccounts: vi.fn().mockResolvedValue(undefined),
16
16
  }));
17
17
 
18
18
  vi.mock("./cc-credentials.js", () => ({
19
- readCCCredentials: vi.fn(),
19
+ readCCCredentials: vi.fn(),
20
20
  }));
21
21
 
22
22
  import type { ManagedAccount } from "./accounts.js";
@@ -28,52 +28,52 @@ const mockSaveAccounts = saveAccounts as Mock;
28
28
  const mockReadCCredentials = readCCCredentials as Mock;
29
29
 
30
30
  function expectAccount(account: ManagedAccount | null): ManagedAccount {
31
- expect(account).not.toBeNull();
32
- return account!;
31
+ expect(account).not.toBeNull();
32
+ return account!;
33
33
  }
34
34
 
35
35
  /** Build a stored account with sensible defaults; override any field. */
36
36
  function makeStoredAccount(overrides: Record<string, unknown> = {}) {
37
- return {
38
- id: `acct-${Math.random().toString(36).slice(2, 8)}`,
39
- refreshToken: "token1",
40
- addedAt: 1000,
41
- lastUsed: 0,
42
- enabled: true,
43
- rateLimitResetTimes: {},
44
- consecutiveFailures: 0,
45
- lastFailureTime: null,
46
- token_updated_at: 0,
47
- stats: createDefaultStats(0),
48
- ...overrides,
49
- };
37
+ return {
38
+ id: `acct-${Math.random().toString(36).slice(2, 8)}`,
39
+ refreshToken: "token1",
40
+ addedAt: 1000,
41
+ lastUsed: 0,
42
+ enabled: true,
43
+ rateLimitResetTimes: {},
44
+ consecutiveFailures: 0,
45
+ lastFailureTime: null,
46
+ token_updated_at: 0,
47
+ stats: createDefaultStats(0),
48
+ ...overrides,
49
+ };
50
50
  }
51
51
 
52
52
  /** Build a stored accounts payload from an array of per-account overrides. */
53
53
  function makeAccountsData(overrides = [{}], extra = {}) {
54
- return {
55
- version: 1,
56
- accounts: overrides.map((o, i) =>
57
- makeStoredAccount({
58
- refreshToken: `token${i + 1}`,
59
- addedAt: (i + 1) * 1000,
60
- ...o,
61
- }),
62
- ),
63
- activeIndex: 0,
64
- ...extra,
65
- };
54
+ return {
55
+ version: 1,
56
+ accounts: overrides.map((o, i) =>
57
+ makeStoredAccount({
58
+ refreshToken: `token${i + 1}`,
59
+ addedAt: (i + 1) * 1000,
60
+ ...o,
61
+ }),
62
+ ),
63
+ activeIndex: 0,
64
+ ...extra,
65
+ };
66
66
  }
67
67
 
68
68
  function makeCCCredential(overrides: Record<string, unknown> = {}) {
69
- return {
70
- accessToken: "cc-access-token",
71
- refreshToken: "cc-refresh-token",
72
- expiresAt: Date.now() + 3600_000,
73
- source: "cc-keychain" as const,
74
- label: "Claude Code-credentials:user@example.com",
75
- ...overrides,
76
- };
69
+ return {
70
+ accessToken: "cc-access-token",
71
+ refreshToken: "cc-refresh-token",
72
+ expiresAt: Date.now() + 3600_000,
73
+ source: "cc-keychain" as const,
74
+ label: "Claude Code-credentials:user@example.com",
75
+ ...overrides,
76
+ };
77
77
  }
78
78
 
79
79
  // ---------------------------------------------------------------------------
@@ -81,176 +81,176 @@ function makeCCCredential(overrides: Record<string, unknown> = {}) {
81
81
  // ---------------------------------------------------------------------------
82
82
 
83
83
  describe("AccountManager.load", () => {
84
- beforeEach(() => {
85
- vi.resetAllMocks();
86
- vi.useFakeTimers();
87
- vi.setSystemTime(new Date("2026-01-15T12:00:00Z"));
88
- mockReadCCredentials.mockReturnValue([]);
89
- });
90
-
91
- it("creates empty manager when no stored accounts and no fallback", async () => {
92
- mockLoadAccounts.mockResolvedValue(null);
93
- const manager = await AccountManager.load(DEFAULT_CONFIG, null);
94
- expect(manager.getAccountCount()).toBe(0);
95
- expect(manager.getTotalAccountCount()).toBe(0);
96
- });
97
-
98
- it("bootstraps from auth fallback when no stored accounts", async () => {
99
- mockLoadAccounts.mockResolvedValue(null);
100
- const manager = await AccountManager.load(DEFAULT_CONFIG, {
101
- refresh: "refresh-token-1",
102
- access: "access-token-1",
103
- expires: Date.now() + 3600_000,
104
- });
105
- expect(manager.getAccountCount()).toBe(1);
106
- expect(manager.getTotalAccountCount()).toBe(1);
107
- expect(manager.getCurrentIndex()).toBe(0);
108
- });
109
-
110
- it("does not bootstrap from auth fallback when storage exists but is empty", async () => {
111
- mockLoadAccounts.mockResolvedValue(makeAccountsData([], { activeIndex: 0 }));
112
- const manager = await AccountManager.load(DEFAULT_CONFIG, {
113
- refresh: "refresh-token-1",
114
- access: "access-token-1",
115
- expires: Date.now() + 3600_000,
116
- });
117
- expect(manager.getAccountCount()).toBe(0);
118
- expect(manager.getTotalAccountCount()).toBe(0);
119
- expect(manager.getCurrentIndex()).toBe(-1);
120
- });
121
-
122
- it("loads stored accounts from disk", async () => {
123
- mockLoadAccounts.mockResolvedValue(
124
- makeAccountsData([{ lastUsed: 2000 }, { lastUsed: 4000 }], {
125
- activeIndex: 1,
126
- }),
127
- );
128
- const manager = await AccountManager.load(DEFAULT_CONFIG, null);
129
- expect(manager.getAccountCount()).toBe(2);
130
- expect(manager.getCurrentIndex()).toBe(1);
131
- });
132
-
133
- it("auto-loads CC credentials on startup and keeps OAuth accounts available", async () => {
134
- mockLoadAccounts.mockResolvedValue(makeAccountsData([{ email: "oauth@example.com", source: "oauth" }]));
135
- mockReadCCredentials.mockReturnValue([
136
- makeCCCredential({
137
- refreshToken: "cc-refresh-1",
138
- accessToken: "cc-access-1",
139
- source: "cc-keychain",
140
- label: "Claude Code-credentials:oauth@example.com",
141
- }),
142
- makeCCCredential({
143
- refreshToken: "cc-refresh-2",
144
- accessToken: "cc-access-2",
145
- source: "cc-file",
146
- label: "/Users/test/.claude/.credentials.json",
147
- }),
148
- ]);
149
-
150
- const config = {
151
- ...DEFAULT_CONFIG,
152
- cc_credential_reuse: {
153
- ...DEFAULT_CONFIG.cc_credential_reuse,
154
- prefer_over_oauth: false,
155
- },
156
- };
84
+ beforeEach(() => {
85
+ vi.resetAllMocks();
86
+ vi.useFakeTimers();
87
+ vi.setSystemTime(new Date("2026-01-15T12:00:00Z"));
88
+ mockReadCCredentials.mockReturnValue([]);
89
+ });
157
90
 
158
- const manager = await AccountManager.load(config, null);
159
- const snapshot = manager.getAccountsSnapshot();
160
-
161
- expect(mockReadCCredentials).toHaveBeenCalledTimes(1);
162
- expect(snapshot).toHaveLength(3);
163
- expect(snapshot.map((account) => account.refreshToken)).toEqual(["token1", "cc-refresh-1", "cc-refresh-2"]);
164
- expect(manager.getOAuthAccounts()).toHaveLength(1);
165
- expect(manager.getCCAccounts().map((account) => account.source)).toEqual(["cc-keychain", "cc-file"]);
166
- // Email collision detection keeps both accounts (CC and OAuth)
167
- });
168
-
169
- it("prefers CC accounts over OAuth when configured", async () => {
170
- mockLoadAccounts.mockResolvedValue(makeAccountsData([{ email: "oauth@example.com", source: "oauth" }]));
171
- mockReadCCredentials.mockReturnValue([
172
- makeCCCredential({
173
- refreshToken: "cc-refresh-1",
174
- accessToken: "cc-access-1",
175
- source: "cc-keychain",
176
- }),
177
- ]);
178
-
179
- const manager = await AccountManager.load(DEFAULT_CONFIG, null);
180
- const snapshot = manager.getAccountsSnapshot();
181
-
182
- expect(snapshot.map((account) => account.source)).toEqual(["cc-keychain", "oauth"]);
183
- expect(snapshot.map((account) => account.index)).toEqual([0, 1]);
184
- expect(manager.getCurrentAccount()?.source).toBe("cc-keychain");
185
- });
186
-
187
- it("stays graceful when CC credentials are unavailable", async () => {
188
- mockLoadAccounts.mockResolvedValue(makeAccountsData([{ email: "oauth@example.com", source: "oauth" }]));
189
- mockReadCCredentials.mockReturnValue([]);
190
-
191
- const manager = await AccountManager.load(DEFAULT_CONFIG, null);
192
-
193
- expect(manager.getTotalAccountCount()).toBe(1);
194
- expect(manager.getAccountsSnapshot()[0]?.refreshToken).toBe("token1");
195
- });
196
-
197
- it("skips CC detection when CC credential reuse is disabled", async () => {
198
- mockLoadAccounts.mockResolvedValue(makeAccountsData([{ email: "oauth@example.com", source: "oauth" }]));
199
-
200
- const config = {
201
- ...DEFAULT_CONFIG,
202
- cc_credential_reuse: {
203
- ...DEFAULT_CONFIG.cc_credential_reuse,
204
- enabled: false,
205
- },
206
- };
91
+ it("creates empty manager when no stored accounts and no fallback", async () => {
92
+ mockLoadAccounts.mockResolvedValue(null);
93
+ const manager = await AccountManager.load(DEFAULT_CONFIG, null);
94
+ expect(manager.getAccountCount()).toBe(0);
95
+ expect(manager.getTotalAccountCount()).toBe(0);
96
+ });
97
+
98
+ it("bootstraps from auth fallback when no stored accounts", async () => {
99
+ mockLoadAccounts.mockResolvedValue(null);
100
+ const manager = await AccountManager.load(DEFAULT_CONFIG, {
101
+ refresh: "refresh-token-1",
102
+ access: "access-token-1",
103
+ expires: Date.now() + 3600_000,
104
+ });
105
+ expect(manager.getAccountCount()).toBe(1);
106
+ expect(manager.getTotalAccountCount()).toBe(1);
107
+ expect(manager.getCurrentIndex()).toBe(0);
108
+ });
109
+
110
+ it("does not bootstrap from auth fallback when storage exists but is empty", async () => {
111
+ mockLoadAccounts.mockResolvedValue(makeAccountsData([], { activeIndex: 0 }));
112
+ const manager = await AccountManager.load(DEFAULT_CONFIG, {
113
+ refresh: "refresh-token-1",
114
+ access: "access-token-1",
115
+ expires: Date.now() + 3600_000,
116
+ });
117
+ expect(manager.getAccountCount()).toBe(0);
118
+ expect(manager.getTotalAccountCount()).toBe(0);
119
+ expect(manager.getCurrentIndex()).toBe(-1);
120
+ });
121
+
122
+ it("loads stored accounts from disk", async () => {
123
+ mockLoadAccounts.mockResolvedValue(
124
+ makeAccountsData([{ lastUsed: 2000 }, { lastUsed: 4000 }], {
125
+ activeIndex: 1,
126
+ }),
127
+ );
128
+ const manager = await AccountManager.load(DEFAULT_CONFIG, null);
129
+ expect(manager.getAccountCount()).toBe(2);
130
+ expect(manager.getCurrentIndex()).toBe(1);
131
+ });
132
+
133
+ it("auto-loads CC credentials on startup and keeps OAuth accounts available", async () => {
134
+ mockLoadAccounts.mockResolvedValue(makeAccountsData([{ email: "oauth@example.com", source: "oauth" }]));
135
+ mockReadCCredentials.mockReturnValue([
136
+ makeCCCredential({
137
+ refreshToken: "cc-refresh-1",
138
+ accessToken: "cc-access-1",
139
+ source: "cc-keychain",
140
+ label: "Claude Code-credentials:oauth@example.com",
141
+ }),
142
+ makeCCCredential({
143
+ refreshToken: "cc-refresh-2",
144
+ accessToken: "cc-access-2",
145
+ source: "cc-file",
146
+ label: "/Users/test/.claude/.credentials.json",
147
+ }),
148
+ ]);
149
+
150
+ const config = {
151
+ ...DEFAULT_CONFIG,
152
+ cc_credential_reuse: {
153
+ ...DEFAULT_CONFIG.cc_credential_reuse,
154
+ prefer_over_oauth: false,
155
+ },
156
+ };
157
+
158
+ const manager = await AccountManager.load(config, null);
159
+ const snapshot = manager.getAccountsSnapshot();
160
+
161
+ expect(mockReadCCredentials).toHaveBeenCalledTimes(1);
162
+ expect(snapshot).toHaveLength(3);
163
+ expect(snapshot.map((account) => account.refreshToken)).toEqual(["token1", "cc-refresh-1", "cc-refresh-2"]);
164
+ expect(manager.getOAuthAccounts()).toHaveLength(1);
165
+ expect(manager.getCCAccounts().map((account) => account.source)).toEqual(["cc-keychain", "cc-file"]);
166
+ // Email collision detection keeps both accounts (CC and OAuth)
167
+ });
168
+
169
+ it("prefers CC accounts over OAuth when configured", async () => {
170
+ mockLoadAccounts.mockResolvedValue(makeAccountsData([{ email: "oauth@example.com", source: "oauth" }]));
171
+ mockReadCCredentials.mockReturnValue([
172
+ makeCCCredential({
173
+ refreshToken: "cc-refresh-1",
174
+ accessToken: "cc-access-1",
175
+ source: "cc-keychain",
176
+ }),
177
+ ]);
178
+
179
+ const manager = await AccountManager.load(DEFAULT_CONFIG, null);
180
+ const snapshot = manager.getAccountsSnapshot();
181
+
182
+ expect(snapshot.map((account) => account.source)).toEqual(["cc-keychain", "oauth"]);
183
+ expect(snapshot.map((account) => account.index)).toEqual([0, 1]);
184
+ expect(manager.getCurrentAccount()?.source).toBe("cc-keychain");
185
+ });
186
+
187
+ it("stays graceful when CC credentials are unavailable", async () => {
188
+ mockLoadAccounts.mockResolvedValue(makeAccountsData([{ email: "oauth@example.com", source: "oauth" }]));
189
+ mockReadCCredentials.mockReturnValue([]);
190
+
191
+ const manager = await AccountManager.load(DEFAULT_CONFIG, null);
192
+
193
+ expect(manager.getTotalAccountCount()).toBe(1);
194
+ expect(manager.getAccountsSnapshot()[0]?.refreshToken).toBe("token1");
195
+ });
196
+
197
+ it("skips CC detection when CC credential reuse is disabled", async () => {
198
+ mockLoadAccounts.mockResolvedValue(makeAccountsData([{ email: "oauth@example.com", source: "oauth" }]));
199
+
200
+ const config = {
201
+ ...DEFAULT_CONFIG,
202
+ cc_credential_reuse: {
203
+ ...DEFAULT_CONFIG.cc_credential_reuse,
204
+ enabled: false,
205
+ },
206
+ };
207
+
208
+ const manager = await AccountManager.load(config, null);
207
209
 
208
- const manager = await AccountManager.load(config, null);
209
-
210
- expect(mockReadCCredentials).not.toHaveBeenCalled();
211
- expect(manager.getTotalAccountCount()).toBe(1);
212
- expect(manager.getCCAccounts()).toEqual([]);
213
- });
214
-
215
- it("matches auth fallback to existing stored account", async () => {
216
- mockLoadAccounts.mockResolvedValue(makeAccountsData([{ lastUsed: 2000 }]));
217
- const manager = await AccountManager.load(DEFAULT_CONFIG, {
218
- refresh: "token1",
219
- access: "fresh-access",
220
- expires: Date.now() + 3600_000,
221
- });
222
- const snapshot = manager.getAccountsSnapshot();
223
- expect(snapshot[0].access).toBe("fresh-access");
224
- });
225
-
226
- it("does not let stale/partial fallback override fresher stored auth", async () => {
227
- mockLoadAccounts.mockResolvedValue(
228
- makeAccountsData([
229
- {
230
- lastUsed: 2000,
231
- access: "disk-access",
232
- expires: Date.now() + 6 * 3600_000,
233
- token_updated_at: Date.now() + 6 * 3600_000,
234
- },
235
- ]),
236
- );
237
-
238
- const manager = await AccountManager.load(DEFAULT_CONFIG, {
239
- refresh: "token1",
240
- access: "stale-fallback-access",
241
- expires: Date.now() - 60_000,
242
- });
243
-
244
- const snapshot = manager.getAccountsSnapshot();
245
- expect(snapshot[0].access).toBe("disk-access");
246
- expect(snapshot[0].expires).toBeGreaterThan(Date.now());
247
- });
248
-
249
- it("clamps activeIndex to valid range", async () => {
250
- mockLoadAccounts.mockResolvedValue(makeAccountsData([{ lastUsed: 2000 }], { activeIndex: 99 }));
251
- const manager = await AccountManager.load(DEFAULT_CONFIG, null);
252
- expect(manager.getCurrentIndex()).toBe(0);
253
- });
210
+ expect(mockReadCCredentials).not.toHaveBeenCalled();
211
+ expect(manager.getTotalAccountCount()).toBe(1);
212
+ expect(manager.getCCAccounts()).toEqual([]);
213
+ });
214
+
215
+ it("matches auth fallback to existing stored account", async () => {
216
+ mockLoadAccounts.mockResolvedValue(makeAccountsData([{ lastUsed: 2000 }]));
217
+ const manager = await AccountManager.load(DEFAULT_CONFIG, {
218
+ refresh: "token1",
219
+ access: "fresh-access",
220
+ expires: Date.now() + 3600_000,
221
+ });
222
+ const snapshot = manager.getAccountsSnapshot();
223
+ expect(snapshot[0].access).toBe("fresh-access");
224
+ });
225
+
226
+ it("does not let stale/partial fallback override fresher stored auth", async () => {
227
+ mockLoadAccounts.mockResolvedValue(
228
+ makeAccountsData([
229
+ {
230
+ lastUsed: 2000,
231
+ access: "disk-access",
232
+ expires: Date.now() + 6 * 3600_000,
233
+ token_updated_at: Date.now() + 6 * 3600_000,
234
+ },
235
+ ]),
236
+ );
237
+
238
+ const manager = await AccountManager.load(DEFAULT_CONFIG, {
239
+ refresh: "token1",
240
+ access: "stale-fallback-access",
241
+ expires: Date.now() - 60_000,
242
+ });
243
+
244
+ const snapshot = manager.getAccountsSnapshot();
245
+ expect(snapshot[0].access).toBe("disk-access");
246
+ expect(snapshot[0].expires).toBeGreaterThan(Date.now());
247
+ });
248
+
249
+ it("clamps activeIndex to valid range", async () => {
250
+ mockLoadAccounts.mockResolvedValue(makeAccountsData([{ lastUsed: 2000 }], { activeIndex: 99 }));
251
+ const manager = await AccountManager.load(DEFAULT_CONFIG, null);
252
+ expect(manager.getCurrentIndex()).toBe(0);
253
+ });
254
254
  });
255
255
 
256
256
  // ---------------------------------------------------------------------------
@@ -258,96 +258,98 @@ describe("AccountManager.load", () => {
258
258
  // ---------------------------------------------------------------------------
259
259
 
260
260
  describe("AccountManager account management", () => {
261
- /** @type {AccountManager} */
262
- let manager: AccountManager;
263
-
264
- beforeEach(async () => {
265
- vi.resetAllMocks();
266
- mockReadCCredentials.mockReturnValue([]);
267
- vi.useFakeTimers();
268
- vi.setSystemTime(new Date("2026-01-15T12:00:00Z"));
269
- mockLoadAccounts.mockResolvedValue(null);
270
- manager = await AccountManager.load(DEFAULT_CONFIG, {
271
- refresh: "token1",
272
- access: "access1",
273
- expires: Date.now() + 3600_000,
274
- });
275
- });
276
-
277
- it("addAccount adds a new account", () => {
278
- const account = expectAccount(manager.addAccount("token2", "access2", Date.now() + 3600_000, "user@example.com"));
279
- expect(manager.getTotalAccountCount()).toBe(2);
280
- expect(account.email).toBe("user@example.com");
281
- });
282
-
283
- it("addAccount deduplicates by refresh token", () => {
284
- manager.addAccount("token1", "new-access", Date.now() + 7200_000);
285
- expect(manager.getTotalAccountCount()).toBe(1);
286
- const snapshot = manager.getAccountsSnapshot();
287
- expect(snapshot[0].access).toBe("new-access");
288
- });
289
-
290
- it("addAccount respects MAX_ACCOUNTS limit", () => {
291
- for (let i = 2; i <= 10; i++) {
292
- manager.addAccount(`token${i}`, `access${i}`, Date.now() + 3600_000);
293
- }
294
- expect(manager.getTotalAccountCount()).toBe(10);
295
- const result = manager.addAccount("token11", "access11", Date.now() + 3600_000);
296
- expect(result).toBeNull();
297
- expect(manager.getTotalAccountCount()).toBe(10);
298
- });
299
-
300
- it("removeAccount removes by index", () => {
301
- manager.addAccount("token2", "access2", Date.now() + 3600_000);
302
- expect(manager.getTotalAccountCount()).toBe(2);
303
- const removed = manager.removeAccount(0);
304
- expect(removed).toBe(true);
305
- expect(manager.getTotalAccountCount()).toBe(1);
306
- const snapshot = manager.getAccountsSnapshot();
307
- expect(snapshot[0].refreshToken).toBe("token2");
308
- expect(snapshot[0].index).toBe(0); // Re-indexed
309
- });
310
-
311
- it("removeAccount returns false for invalid index", () => {
312
- expect(manager.removeAccount(-1)).toBe(false);
313
- expect(manager.removeAccount(99)).toBe(false);
314
- });
315
-
316
- it("removeAccount adjusts currentIndex", () => {
317
- manager.addAccount("token2", "access2", Date.now() + 3600_000);
318
- // Select account 1
319
- manager.getCurrentAccount();
320
- manager.removeAccount(0);
321
- // currentIndex should be adjusted
322
- expect(manager.getCurrentIndex()).toBeLessThanOrEqual(0);
323
- });
324
-
325
- it("toggleAccount toggles enabled state", () => {
326
- const newState = manager.toggleAccount(0);
327
- expect(newState).toBe(false);
328
- expect(manager.getAccountCount()).toBe(0); // Disabled
329
- const restored = manager.toggleAccount(0);
330
- expect(restored).toBe(true);
331
- expect(manager.getAccountCount()).toBe(1);
332
- });
333
-
334
- it("toggleAccount returns false for invalid index", () => {
335
- expect(manager.toggleAccount(99)).toBe(false);
336
- });
337
-
338
- it("clearAll removes all accounts", () => {
339
- manager.addAccount("token2", "access2", Date.now() + 3600_000);
340
- manager.clearAll();
341
- expect(manager.getTotalAccountCount()).toBe(0);
342
- expect(manager.getCurrentIndex()).toBe(-1);
343
- });
344
-
345
- it("getAccountsSnapshot returns copies", () => {
346
- const snapshot = manager.getAccountsSnapshot();
347
- snapshot[0].email = "modified";
348
- const snapshot2 = manager.getAccountsSnapshot();
349
- expect(snapshot2[0].email).not.toBe("modified");
350
- });
261
+ /** @type {AccountManager} */
262
+ let manager: AccountManager;
263
+
264
+ beforeEach(async () => {
265
+ vi.resetAllMocks();
266
+ mockReadCCredentials.mockReturnValue([]);
267
+ vi.useFakeTimers();
268
+ vi.setSystemTime(new Date("2026-01-15T12:00:00Z"));
269
+ mockLoadAccounts.mockResolvedValue(null);
270
+ manager = await AccountManager.load(DEFAULT_CONFIG, {
271
+ refresh: "token1",
272
+ access: "access1",
273
+ expires: Date.now() + 3600_000,
274
+ });
275
+ });
276
+
277
+ it("addAccount adds a new account", () => {
278
+ const account = expectAccount(
279
+ manager.addAccount("token2", "access2", Date.now() + 3600_000, "user@example.com"),
280
+ );
281
+ expect(manager.getTotalAccountCount()).toBe(2);
282
+ expect(account.email).toBe("user@example.com");
283
+ });
284
+
285
+ it("addAccount deduplicates by refresh token", () => {
286
+ manager.addAccount("token1", "new-access", Date.now() + 7200_000);
287
+ expect(manager.getTotalAccountCount()).toBe(1);
288
+ const snapshot = manager.getAccountsSnapshot();
289
+ expect(snapshot[0].access).toBe("new-access");
290
+ });
291
+
292
+ it("addAccount respects MAX_ACCOUNTS limit", () => {
293
+ for (let i = 2; i <= 10; i++) {
294
+ manager.addAccount(`token${i}`, `access${i}`, Date.now() + 3600_000);
295
+ }
296
+ expect(manager.getTotalAccountCount()).toBe(10);
297
+ const result = manager.addAccount("token11", "access11", Date.now() + 3600_000);
298
+ expect(result).toBeNull();
299
+ expect(manager.getTotalAccountCount()).toBe(10);
300
+ });
301
+
302
+ it("removeAccount removes by index", () => {
303
+ manager.addAccount("token2", "access2", Date.now() + 3600_000);
304
+ expect(manager.getTotalAccountCount()).toBe(2);
305
+ const removed = manager.removeAccount(0);
306
+ expect(removed).toBe(true);
307
+ expect(manager.getTotalAccountCount()).toBe(1);
308
+ const snapshot = manager.getAccountsSnapshot();
309
+ expect(snapshot[0].refreshToken).toBe("token2");
310
+ expect(snapshot[0].index).toBe(0); // Re-indexed
311
+ });
312
+
313
+ it("removeAccount returns false for invalid index", () => {
314
+ expect(manager.removeAccount(-1)).toBe(false);
315
+ expect(manager.removeAccount(99)).toBe(false);
316
+ });
317
+
318
+ it("removeAccount adjusts currentIndex", () => {
319
+ manager.addAccount("token2", "access2", Date.now() + 3600_000);
320
+ // Select account 1
321
+ manager.getCurrentAccount();
322
+ manager.removeAccount(0);
323
+ // currentIndex should be adjusted
324
+ expect(manager.getCurrentIndex()).toBeLessThanOrEqual(0);
325
+ });
326
+
327
+ it("toggleAccount toggles enabled state", () => {
328
+ const newState = manager.toggleAccount(0);
329
+ expect(newState).toBe(false);
330
+ expect(manager.getAccountCount()).toBe(0); // Disabled
331
+ const restored = manager.toggleAccount(0);
332
+ expect(restored).toBe(true);
333
+ expect(manager.getAccountCount()).toBe(1);
334
+ });
335
+
336
+ it("toggleAccount returns false for invalid index", () => {
337
+ expect(manager.toggleAccount(99)).toBe(false);
338
+ });
339
+
340
+ it("clearAll removes all accounts", () => {
341
+ manager.addAccount("token2", "access2", Date.now() + 3600_000);
342
+ manager.clearAll();
343
+ expect(manager.getTotalAccountCount()).toBe(0);
344
+ expect(manager.getCurrentIndex()).toBe(-1);
345
+ });
346
+
347
+ it("getAccountsSnapshot returns copies", () => {
348
+ const snapshot = manager.getAccountsSnapshot();
349
+ snapshot[0].email = "modified";
350
+ const snapshot2 = manager.getAccountsSnapshot();
351
+ expect(snapshot2[0].email).not.toBe("modified");
352
+ });
351
353
  });
352
354
 
353
355
  // ---------------------------------------------------------------------------
@@ -355,40 +357,40 @@ describe("AccountManager account management", () => {
355
357
  // ---------------------------------------------------------------------------
356
358
 
357
359
  describe("AccountManager account selection", () => {
358
- /** @type {AccountManager} */
359
- let manager: AccountManager;
360
-
361
- beforeEach(async () => {
362
- vi.resetAllMocks();
363
- mockReadCCredentials.mockReturnValue([]);
364
- vi.useFakeTimers();
365
- vi.setSystemTime(new Date("2026-01-15T12:00:00Z"));
366
- mockLoadAccounts.mockResolvedValue(null);
367
- manager = await AccountManager.load(DEFAULT_CONFIG, {
368
- refresh: "token1",
369
- access: "access1",
370
- expires: Date.now() + 3600_000,
371
- });
372
- });
373
-
374
- it("getCurrentAccount returns an account", () => {
375
- const account = expectAccount(manager.getCurrentAccount());
376
- expect(account.refreshToken).toBe("token1");
377
- });
378
-
379
- it("getCurrentAccount returns null when no accounts", async () => {
380
- mockLoadAccounts.mockResolvedValue(null);
381
- const empty = await AccountManager.load(DEFAULT_CONFIG, null);
382
- expect(empty.getCurrentAccount()).toBeNull();
383
- });
384
-
385
- it("getCurrentAccount updates lastUsed", () => {
386
- const before = manager.getAccountsSnapshot()[0].lastUsed;
387
- vi.advanceTimersByTime(1000);
388
- manager.getCurrentAccount();
389
- const after = manager.getAccountsSnapshot()[0].lastUsed;
390
- expect(after).toBeGreaterThan(before);
391
- });
360
+ /** @type {AccountManager} */
361
+ let manager: AccountManager;
362
+
363
+ beforeEach(async () => {
364
+ vi.resetAllMocks();
365
+ mockReadCCredentials.mockReturnValue([]);
366
+ vi.useFakeTimers();
367
+ vi.setSystemTime(new Date("2026-01-15T12:00:00Z"));
368
+ mockLoadAccounts.mockResolvedValue(null);
369
+ manager = await AccountManager.load(DEFAULT_CONFIG, {
370
+ refresh: "token1",
371
+ access: "access1",
372
+ expires: Date.now() + 3600_000,
373
+ });
374
+ });
375
+
376
+ it("getCurrentAccount returns an account", () => {
377
+ const account = expectAccount(manager.getCurrentAccount());
378
+ expect(account.refreshToken).toBe("token1");
379
+ });
380
+
381
+ it("getCurrentAccount returns null when no accounts", async () => {
382
+ mockLoadAccounts.mockResolvedValue(null);
383
+ const empty = await AccountManager.load(DEFAULT_CONFIG, null);
384
+ expect(empty.getCurrentAccount()).toBeNull();
385
+ });
386
+
387
+ it("getCurrentAccount updates lastUsed", () => {
388
+ const before = manager.getAccountsSnapshot()[0].lastUsed;
389
+ vi.advanceTimersByTime(1000);
390
+ manager.getCurrentAccount();
391
+ const after = manager.getAccountsSnapshot()[0].lastUsed;
392
+ expect(after).toBeGreaterThan(before);
393
+ });
392
394
  });
393
395
 
394
396
  // ---------------------------------------------------------------------------
@@ -396,65 +398,65 @@ describe("AccountManager account selection", () => {
396
398
  // ---------------------------------------------------------------------------
397
399
 
398
400
  describe("AccountManager rate limiting", () => {
399
- /** @type {AccountManager} */
400
- let manager: AccountManager;
401
-
402
- beforeEach(async () => {
403
- vi.resetAllMocks();
404
- mockReadCCredentials.mockReturnValue([]);
405
- vi.useFakeTimers();
406
- vi.setSystemTime(new Date("2026-01-15T12:00:00Z"));
407
- mockLoadAccounts.mockResolvedValue(null);
408
- manager = await AccountManager.load(DEFAULT_CONFIG, {
409
- refresh: "token1",
410
- access: "access1",
411
- expires: Date.now() + 3600_000,
412
- });
413
- manager.addAccount("token2", "access2", Date.now() + 3600_000);
414
- });
415
-
416
- it("markRateLimited sets backoff and returns duration", () => {
417
- const account = expectAccount(manager.getCurrentAccount());
418
- const backoffMs = manager.markRateLimited(account, "RATE_LIMIT_EXCEEDED", null);
419
- expect(backoffMs).toBeGreaterThan(0);
420
- expect(account.consecutiveFailures).toBe(1);
421
- });
422
-
423
- it("markRateLimited increments consecutive failures", () => {
424
- const account = expectAccount(manager.getCurrentAccount());
425
- manager.markRateLimited(account, "RATE_LIMIT_EXCEEDED", null);
426
- manager.markRateLimited(account, "RATE_LIMIT_EXCEEDED", null);
427
- expect(account.consecutiveFailures).toBe(2);
428
- });
429
-
430
- it("markSuccess resets consecutive failures", () => {
431
- const account = expectAccount(manager.getCurrentAccount());
432
- manager.markRateLimited(account, "RATE_LIMIT_EXCEEDED", null);
433
- expect(account.consecutiveFailures).toBe(1);
434
- manager.markSuccess(account);
435
- expect(account.consecutiveFailures).toBe(0);
436
- expect(account.lastFailureTime).toBeNull();
437
- });
438
-
439
- it("markFailure reduces health score", () => {
440
- const account = expectAccount(manager.getCurrentAccount());
441
- manager.markFailure(account);
442
- // Can't directly check health score, but we can verify it doesn't crash
443
- expect(account).toBeDefined();
444
- });
445
-
446
- it("failure TTL resets consecutive failures after timeout", () => {
447
- const account = expectAccount(manager.getCurrentAccount());
448
- manager.markRateLimited(account, "RATE_LIMIT_EXCEEDED", null);
449
- expect(account.consecutiveFailures).toBe(1);
450
-
451
- // Advance past failure TTL (3600 seconds)
452
- vi.advanceTimersByTime(3601_000);
453
-
454
- // Next rate limit should reset the counter first
455
- manager.markRateLimited(account, "RATE_LIMIT_EXCEEDED", null);
456
- expect(account.consecutiveFailures).toBe(1); // Reset to 0, then +1
457
- });
401
+ /** @type {AccountManager} */
402
+ let manager: AccountManager;
403
+
404
+ beforeEach(async () => {
405
+ vi.resetAllMocks();
406
+ mockReadCCredentials.mockReturnValue([]);
407
+ vi.useFakeTimers();
408
+ vi.setSystemTime(new Date("2026-01-15T12:00:00Z"));
409
+ mockLoadAccounts.mockResolvedValue(null);
410
+ manager = await AccountManager.load(DEFAULT_CONFIG, {
411
+ refresh: "token1",
412
+ access: "access1",
413
+ expires: Date.now() + 3600_000,
414
+ });
415
+ manager.addAccount("token2", "access2", Date.now() + 3600_000);
416
+ });
417
+
418
+ it("markRateLimited sets backoff and returns duration", () => {
419
+ const account = expectAccount(manager.getCurrentAccount());
420
+ const backoffMs = manager.markRateLimited(account, "RATE_LIMIT_EXCEEDED", null);
421
+ expect(backoffMs).toBeGreaterThan(0);
422
+ expect(account.consecutiveFailures).toBe(1);
423
+ });
424
+
425
+ it("markRateLimited increments consecutive failures", () => {
426
+ const account = expectAccount(manager.getCurrentAccount());
427
+ manager.markRateLimited(account, "RATE_LIMIT_EXCEEDED", null);
428
+ manager.markRateLimited(account, "RATE_LIMIT_EXCEEDED", null);
429
+ expect(account.consecutiveFailures).toBe(2);
430
+ });
431
+
432
+ it("markSuccess resets consecutive failures", () => {
433
+ const account = expectAccount(manager.getCurrentAccount());
434
+ manager.markRateLimited(account, "RATE_LIMIT_EXCEEDED", null);
435
+ expect(account.consecutiveFailures).toBe(1);
436
+ manager.markSuccess(account);
437
+ expect(account.consecutiveFailures).toBe(0);
438
+ expect(account.lastFailureTime).toBeNull();
439
+ });
440
+
441
+ it("markFailure reduces health score", () => {
442
+ const account = expectAccount(manager.getCurrentAccount());
443
+ manager.markFailure(account);
444
+ // Can't directly check health score, but we can verify it doesn't crash
445
+ expect(account).toBeDefined();
446
+ });
447
+
448
+ it("failure TTL resets consecutive failures after timeout", () => {
449
+ const account = expectAccount(manager.getCurrentAccount());
450
+ manager.markRateLimited(account, "RATE_LIMIT_EXCEEDED", null);
451
+ expect(account.consecutiveFailures).toBe(1);
452
+
453
+ // Advance past failure TTL (3600 seconds)
454
+ vi.advanceTimersByTime(3601_000);
455
+
456
+ // Next rate limit should reset the counter first
457
+ manager.markRateLimited(account, "RATE_LIMIT_EXCEEDED", null);
458
+ expect(account.consecutiveFailures).toBe(1); // Reset to 0, then +1
459
+ });
458
460
  });
459
461
 
460
462
  // ---------------------------------------------------------------------------
@@ -462,166 +464,167 @@ describe("AccountManager rate limiting", () => {
462
464
  // ---------------------------------------------------------------------------
463
465
 
464
466
  describe("AccountManager persistence", () => {
465
- /** @type {AccountManager} */
466
- let manager: AccountManager;
467
-
468
- beforeEach(async () => {
469
- vi.resetAllMocks();
470
- mockReadCCredentials.mockReturnValue([]);
471
- vi.useFakeTimers();
472
- vi.setSystemTime(new Date("2026-01-15T12:00:00Z"));
473
- mockLoadAccounts.mockResolvedValue(null);
474
- mockSaveAccounts.mockResolvedValue(undefined);
475
- manager = await AccountManager.load(DEFAULT_CONFIG, {
476
- refresh: "token1",
477
- access: "access1",
478
- expires: Date.now() + 3600_000,
479
- });
480
- });
481
-
482
- it("saveToDisk calls saveAccounts with correct format", async () => {
483
- await manager.saveToDisk();
484
- expect(saveAccounts).toHaveBeenCalledWith(
485
- expect.objectContaining({
486
- version: 1,
487
- accounts: expect.arrayContaining([
488
- expect.objectContaining({
489
- refreshToken: "token1",
490
- enabled: true,
491
- }),
492
- ]),
493
- activeIndex: expect.any(Number),
494
- }),
495
- );
496
- });
497
-
498
- it("requestSaveToDisk debounces saves", async () => {
499
- manager.requestSaveToDisk();
500
- manager.requestSaveToDisk();
501
- manager.requestSaveToDisk();
502
-
503
- // Should not have saved yet
504
- expect(saveAccounts).not.toHaveBeenCalled();
505
-
506
- // Advance past debounce timeout
507
- vi.advanceTimersByTime(1100);
508
-
509
- // Should have saved once
510
- // Wait for the async save to complete
511
- await vi.runAllTimersAsync();
512
- expect(saveAccounts).toHaveBeenCalledTimes(1);
513
- });
514
-
515
- it("requestSaveToDisk resets timer on subsequent calls", async () => {
516
- manager.requestSaveToDisk();
517
- vi.advanceTimersByTime(500); // Half the debounce window
518
- manager.requestSaveToDisk(); // Should reset the timer
519
- vi.advanceTimersByTime(500); // 500ms after second call (total 1000ms)
520
- expect(saveAccounts).not.toHaveBeenCalled(); // Timer was reset
521
- vi.advanceTimersByTime(600); // Now past the debounce window
522
- await vi.runAllTimersAsync();
523
- expect(saveAccounts).toHaveBeenCalledTimes(1);
524
- });
525
-
526
- it("toAuthDetails converts to OpenCode format", () => {
527
- const account = expectAccount(manager.getCurrentAccount());
528
- const details = manager.toAuthDetails(account);
529
- expect(details).toEqual({
530
- type: "oauth",
531
- refresh: "token1",
532
- access: "access1",
533
- expires: expect.any(Number),
534
- });
535
- });
536
-
537
- it("syncActiveIndexFromDisk picks up CLI changes", async () => {
538
- manager.addAccount("token2", "access2", Date.now() + 3600_000, "b@test.com");
539
-
540
- // Currently on account 0
541
- expect(manager.getCurrentIndex()).toBe(0);
542
-
543
- // CLI changes activeIndex to 1 on disk
544
- mockLoadAccounts.mockResolvedValue(
545
- makeAccountsData([{ email: "a@test.com" }, { email: "b@test.com" }], {
546
- activeIndex: 1,
547
- }),
548
- );
549
-
550
- await manager.syncActiveIndexFromDisk();
551
- expect(manager.getCurrentIndex()).toBe(1);
552
- });
553
-
554
- it("syncActiveIndexFromDisk ignores disabled target account", async () => {
555
- manager.addAccount("token2", "access2", Date.now() + 3600_000, "b@test.com");
556
-
557
- expect(manager.getCurrentIndex()).toBe(0);
558
-
559
- mockLoadAccounts.mockResolvedValue(
560
- makeAccountsData([{ email: "a@test.com" }, { email: "b@test.com", enabled: false }], { activeIndex: 1 }),
561
- );
562
-
563
- await manager.syncActiveIndexFromDisk();
564
- // Should stay on 0 because account 1 is disabled
565
- expect(manager.getCurrentIndex()).toBe(0);
566
- });
567
-
568
- it("syncActiveIndexFromDisk no-ops when disk matches memory", async () => {
569
- manager.addAccount("token2", "access2", Date.now() + 3600_000, "b@test.com");
570
-
571
- expect(manager.getCurrentIndex()).toBe(0);
572
-
573
- mockLoadAccounts.mockResolvedValue(makeAccountsData([{ email: "a@test.com" }, { email: "b@test.com" }]));
574
-
575
- await manager.syncActiveIndexFromDisk();
576
- expect(manager.getCurrentIndex()).toBe(0);
577
- });
578
-
579
- it("syncActiveIndexFromDisk reconciles removed accounts from disk", async () => {
580
- manager.addAccount("token2", "access2", Date.now() + 3600_000, "b@test.com");
581
-
582
- mockLoadAccounts.mockResolvedValue(makeAccountsData([{ email: "a@test.com" }]));
583
-
584
- await manager.syncActiveIndexFromDisk();
585
- await manager.saveToDisk();
586
-
587
- const saved = mockSaveAccounts.mock.calls[mockSaveAccounts.mock.calls.length - 1]?.[0];
588
- expect(saved.accounts).toHaveLength(1);
589
- expect(saved.accounts[0].refreshToken).toBe("token1");
590
- });
591
-
592
- it("syncActiveIndexFromDisk updates enabled state from disk", async () => {
593
- manager.addAccount("token2", "access2", Date.now() + 3600_000, "b@test.com");
594
-
595
- mockLoadAccounts.mockResolvedValue(
596
- makeAccountsData([{ email: "a@test.com", enabled: false }, { email: "b@test.com" }], { activeIndex: 1 }),
597
- );
598
-
599
- await manager.syncActiveIndexFromDisk();
600
- expect(manager.getCurrentIndex()).toBe(1);
601
- });
602
-
603
- it("syncActiveIndexFromDisk reconciles by stable id when refresh token rotates", async () => {
604
- const current = manager.getAccountsSnapshot()[0];
605
-
606
- mockLoadAccounts.mockResolvedValue(
607
- makeAccountsData([
608
- {
609
- id: current.id,
610
- refreshToken: "token1-rotated",
611
- access: "rotated-access",
612
- expires: Date.now() + 7200_000,
613
- token_updated_at: Date.now() + 5_000,
614
- },
615
- ]),
616
- );
617
-
618
- await manager.syncActiveIndexFromDisk();
619
- const snapshot = manager.getAccountsSnapshot();
620
-
621
- expect(snapshot[0].id).toBe(current.id);
622
- expect(snapshot[0].refreshToken).toBe("token1-rotated");
623
- expect(snapshot[0].access).toBe("rotated-access");
624
- });
467
+ /** @type {AccountManager} */
468
+ let manager: AccountManager;
469
+
470
+ beforeEach(async () => {
471
+ vi.resetAllMocks();
472
+ mockReadCCredentials.mockReturnValue([]);
473
+ vi.useFakeTimers();
474
+ vi.setSystemTime(new Date("2026-01-15T12:00:00Z"));
475
+ mockLoadAccounts.mockResolvedValue(null);
476
+ mockSaveAccounts.mockResolvedValue(undefined);
477
+ manager = await AccountManager.load(DEFAULT_CONFIG, {
478
+ refresh: "token1",
479
+ access: "access1",
480
+ expires: Date.now() + 3600_000,
481
+ });
482
+ });
483
+
484
+ it("saveToDisk calls saveAccounts with correct format", async () => {
485
+ await manager.saveToDisk();
486
+ expect(saveAccounts).toHaveBeenCalledWith(
487
+ expect.objectContaining({
488
+ version: 1,
489
+ accounts: expect.arrayContaining([
490
+ expect.objectContaining({
491
+ refreshToken: "token1",
492
+ enabled: true,
493
+ }),
494
+ ]),
495
+ activeIndex: expect.any(Number),
496
+ }),
497
+ expect.anything(),
498
+ );
499
+ });
500
+
501
+ it("requestSaveToDisk debounces saves", async () => {
502
+ manager.requestSaveToDisk();
503
+ manager.requestSaveToDisk();
504
+ manager.requestSaveToDisk();
505
+
506
+ // Should not have saved yet
507
+ expect(saveAccounts).not.toHaveBeenCalled();
508
+
509
+ // Advance past debounce timeout
510
+ vi.advanceTimersByTime(1100);
511
+
512
+ // Should have saved once
513
+ // Wait for the async save to complete
514
+ await vi.runAllTimersAsync();
515
+ expect(saveAccounts).toHaveBeenCalledTimes(1);
516
+ });
517
+
518
+ it("requestSaveToDisk resets timer on subsequent calls", async () => {
519
+ manager.requestSaveToDisk();
520
+ vi.advanceTimersByTime(500); // Half the debounce window
521
+ manager.requestSaveToDisk(); // Should reset the timer
522
+ vi.advanceTimersByTime(500); // 500ms after second call (total 1000ms)
523
+ expect(saveAccounts).not.toHaveBeenCalled(); // Timer was reset
524
+ vi.advanceTimersByTime(600); // Now past the debounce window
525
+ await vi.runAllTimersAsync();
526
+ expect(saveAccounts).toHaveBeenCalledTimes(1);
527
+ });
528
+
529
+ it("toAuthDetails converts to OpenCode format", () => {
530
+ const account = expectAccount(manager.getCurrentAccount());
531
+ const details = manager.toAuthDetails(account);
532
+ expect(details).toEqual({
533
+ type: "oauth",
534
+ refresh: "token1",
535
+ access: "access1",
536
+ expires: expect.any(Number),
537
+ });
538
+ });
539
+
540
+ it("syncActiveIndexFromDisk picks up CLI changes", async () => {
541
+ manager.addAccount("token2", "access2", Date.now() + 3600_000, "b@test.com");
542
+
543
+ // Currently on account 0
544
+ expect(manager.getCurrentIndex()).toBe(0);
545
+
546
+ // CLI changes activeIndex to 1 on disk
547
+ mockLoadAccounts.mockResolvedValue(
548
+ makeAccountsData([{ email: "a@test.com" }, { email: "b@test.com" }], {
549
+ activeIndex: 1,
550
+ }),
551
+ );
552
+
553
+ await manager.syncActiveIndexFromDisk();
554
+ expect(manager.getCurrentIndex()).toBe(1);
555
+ });
556
+
557
+ it("syncActiveIndexFromDisk ignores disabled target account", async () => {
558
+ manager.addAccount("token2", "access2", Date.now() + 3600_000, "b@test.com");
559
+
560
+ expect(manager.getCurrentIndex()).toBe(0);
561
+
562
+ mockLoadAccounts.mockResolvedValue(
563
+ makeAccountsData([{ email: "a@test.com" }, { email: "b@test.com", enabled: false }], { activeIndex: 1 }),
564
+ );
565
+
566
+ await manager.syncActiveIndexFromDisk();
567
+ // Should stay on 0 because account 1 is disabled
568
+ expect(manager.getCurrentIndex()).toBe(0);
569
+ });
570
+
571
+ it("syncActiveIndexFromDisk no-ops when disk matches memory", async () => {
572
+ manager.addAccount("token2", "access2", Date.now() + 3600_000, "b@test.com");
573
+
574
+ expect(manager.getCurrentIndex()).toBe(0);
575
+
576
+ mockLoadAccounts.mockResolvedValue(makeAccountsData([{ email: "a@test.com" }, { email: "b@test.com" }]));
577
+
578
+ await manager.syncActiveIndexFromDisk();
579
+ expect(manager.getCurrentIndex()).toBe(0);
580
+ });
581
+
582
+ it("syncActiveIndexFromDisk reconciles removed accounts from disk", async () => {
583
+ manager.addAccount("token2", "access2", Date.now() + 3600_000, "b@test.com");
584
+
585
+ mockLoadAccounts.mockResolvedValue(makeAccountsData([{ email: "a@test.com" }]));
586
+
587
+ await manager.syncActiveIndexFromDisk();
588
+ await manager.saveToDisk();
589
+
590
+ const saved = mockSaveAccounts.mock.calls[mockSaveAccounts.mock.calls.length - 1]?.[0];
591
+ expect(saved.accounts).toHaveLength(1);
592
+ expect(saved.accounts[0].refreshToken).toBe("token1");
593
+ });
594
+
595
+ it("syncActiveIndexFromDisk updates enabled state from disk", async () => {
596
+ manager.addAccount("token2", "access2", Date.now() + 3600_000, "b@test.com");
597
+
598
+ mockLoadAccounts.mockResolvedValue(
599
+ makeAccountsData([{ email: "a@test.com", enabled: false }, { email: "b@test.com" }], { activeIndex: 1 }),
600
+ );
601
+
602
+ await manager.syncActiveIndexFromDisk();
603
+ expect(manager.getCurrentIndex()).toBe(1);
604
+ });
605
+
606
+ it("syncActiveIndexFromDisk reconciles by stable id when refresh token rotates", async () => {
607
+ const current = manager.getAccountsSnapshot()[0];
608
+
609
+ mockLoadAccounts.mockResolvedValue(
610
+ makeAccountsData([
611
+ {
612
+ id: current.id,
613
+ refreshToken: "token1-rotated",
614
+ access: "rotated-access",
615
+ expires: Date.now() + 7200_000,
616
+ token_updated_at: Date.now() + 5_000,
617
+ },
618
+ ]),
619
+ );
620
+
621
+ await manager.syncActiveIndexFromDisk();
622
+ const snapshot = manager.getAccountsSnapshot();
623
+
624
+ expect(snapshot[0].id).toBe(current.id);
625
+ expect(snapshot[0].refreshToken).toBe("token1-rotated");
626
+ expect(snapshot[0].access).toBe("rotated-access");
627
+ });
625
628
  });
626
629
 
627
630
  // ---------------------------------------------------------------------------
@@ -629,129 +632,130 @@ describe("AccountManager persistence", () => {
629
632
  // ---------------------------------------------------------------------------
630
633
 
631
634
  describe("AccountManager usage stats", () => {
632
- beforeEach(() => {
633
- vi.clearAllMocks();
634
- });
635
-
636
- async function createManagerWithAccounts(n = 2) {
637
- mockLoadAccounts.mockResolvedValue(null);
638
- const manager = await AccountManager.load(DEFAULT_CONFIG, {
639
- refresh: "tok-1",
640
- access: "access-1",
641
- expires: Date.now() + 3600_000,
642
- });
643
- for (let i = 2; i <= n; i++) {
644
- manager.addAccount(`tok-${i}`, `access-${i}`, Date.now() + 3600_000, `user${i}@test.com`);
635
+ beforeEach(() => {
636
+ vi.clearAllMocks();
637
+ });
638
+
639
+ async function createManagerWithAccounts(n = 2) {
640
+ mockLoadAccounts.mockResolvedValue(null);
641
+ const manager = await AccountManager.load(DEFAULT_CONFIG, {
642
+ refresh: "tok-1",
643
+ access: "access-1",
644
+ expires: Date.now() + 3600_000,
645
+ });
646
+ for (let i = 2; i <= n; i++) {
647
+ manager.addAccount(`tok-${i}`, `access-${i}`, Date.now() + 3600_000, `user${i}@test.com`);
648
+ }
649
+ return manager;
645
650
  }
646
- return manager;
647
- }
648
-
649
- it("recordUsage increments stats for the given account", async () => {
650
- const manager = await createManagerWithAccounts(2);
651
- manager.recordUsage(0, {
652
- inputTokens: 100,
653
- outputTokens: 50,
654
- cacheReadTokens: 10,
655
- cacheWriteTokens: 5,
656
- });
657
-
658
- const snap = manager.getAccountsSnapshot();
659
- expect(snap[0].stats.requests).toBe(1);
660
- expect(snap[0].stats.inputTokens).toBe(100);
661
- expect(snap[0].stats.outputTokens).toBe(50);
662
- expect(snap[0].stats.cacheReadTokens).toBe(10);
663
- expect(snap[0].stats.cacheWriteTokens).toBe(5);
664
- // Account 1 should be untouched
665
- expect(snap[1].stats.requests).toBe(0);
666
- });
667
-
668
- it("recordUsage accumulates over multiple calls", async () => {
669
- const manager = await createManagerWithAccounts(1);
670
- manager.recordUsage(0, { inputTokens: 100, outputTokens: 50 });
671
- manager.recordUsage(0, { inputTokens: 200, outputTokens: 100 });
672
-
673
- const snap = manager.getAccountsSnapshot();
674
- expect(snap[0].stats.requests).toBe(2);
675
- expect(snap[0].stats.inputTokens).toBe(300);
676
- expect(snap[0].stats.outputTokens).toBe(150);
677
- });
678
-
679
- it("recordUsage handles missing fields gracefully", async () => {
680
- const manager = await createManagerWithAccounts(1);
681
- manager.recordUsage(0, {});
682
-
683
- const snap = manager.getAccountsSnapshot();
684
- expect(snap[0].stats.requests).toBe(1);
685
- expect(snap[0].stats.inputTokens).toBe(0);
686
- });
687
-
688
- it("recordUsage ignores invalid index", async () => {
689
- const manager = await createManagerWithAccounts(1);
690
- manager.recordUsage(99, { inputTokens: 100 });
691
- // Should not throw
692
- const snap = manager.getAccountsSnapshot();
693
- expect(snap[0].stats.requests).toBe(0);
694
- });
695
-
696
- it("resetStats resets a single account", async () => {
697
- const manager = await createManagerWithAccounts(2);
698
- manager.recordUsage(0, { inputTokens: 500, outputTokens: 200 });
699
- manager.recordUsage(1, { inputTokens: 300, outputTokens: 100 });
700
-
701
- manager.resetStats(0);
702
-
703
- const snap = manager.getAccountsSnapshot();
704
- expect(snap[0].stats.requests).toBe(0);
705
- expect(snap[0].stats.inputTokens).toBe(0);
706
- // Account 1 should be untouched
707
- expect(snap[1].stats.requests).toBe(1);
708
- expect(snap[1].stats.inputTokens).toBe(300);
709
- });
710
-
711
- it("resetStats ignores invalid index", async () => {
712
- const manager = await createManagerWithAccounts(1);
713
- manager.recordUsage(0, { inputTokens: 500 });
714
-
715
- manager.resetStats(99);
716
-
717
- // Account 0 should be untouched
718
- const snap = manager.getAccountsSnapshot();
719
- expect(snap[0].stats.requests).toBe(1);
720
- expect(snap[0].stats.inputTokens).toBe(500);
721
- });
722
-
723
- it("resetStats resets all accounts", async () => {
724
- const manager = await createManagerWithAccounts(2);
725
- manager.recordUsage(0, { inputTokens: 500 });
726
- manager.recordUsage(1, { inputTokens: 300 });
727
-
728
- manager.resetStats("all");
729
-
730
- const snap = manager.getAccountsSnapshot();
731
- expect(snap[0].stats.requests).toBe(0);
732
- expect(snap[1].stats.requests).toBe(0);
733
- });
734
-
735
- it("stats are included in saveToDisk output", async () => {
736
- const manager = await createManagerWithAccounts(1);
737
- manager.recordUsage(0, { inputTokens: 100, outputTokens: 50 });
738
-
739
- await manager.saveToDisk();
740
-
741
- expect(saveAccounts).toHaveBeenCalledWith(
742
- expect.objectContaining({
743
- accounts: expect.arrayContaining([
744
- expect.objectContaining({
745
- stats: expect.objectContaining({
746
- requests: 1,
747
- inputTokens: 100,
748
- outputTokens: 50,
651
+
652
+ it("recordUsage increments stats for the given account", async () => {
653
+ const manager = await createManagerWithAccounts(2);
654
+ manager.recordUsage(0, {
655
+ inputTokens: 100,
656
+ outputTokens: 50,
657
+ cacheReadTokens: 10,
658
+ cacheWriteTokens: 5,
659
+ });
660
+
661
+ const snap = manager.getAccountsSnapshot();
662
+ expect(snap[0].stats.requests).toBe(1);
663
+ expect(snap[0].stats.inputTokens).toBe(100);
664
+ expect(snap[0].stats.outputTokens).toBe(50);
665
+ expect(snap[0].stats.cacheReadTokens).toBe(10);
666
+ expect(snap[0].stats.cacheWriteTokens).toBe(5);
667
+ // Account 1 should be untouched
668
+ expect(snap[1].stats.requests).toBe(0);
669
+ });
670
+
671
+ it("recordUsage accumulates over multiple calls", async () => {
672
+ const manager = await createManagerWithAccounts(1);
673
+ manager.recordUsage(0, { inputTokens: 100, outputTokens: 50 });
674
+ manager.recordUsage(0, { inputTokens: 200, outputTokens: 100 });
675
+
676
+ const snap = manager.getAccountsSnapshot();
677
+ expect(snap[0].stats.requests).toBe(2);
678
+ expect(snap[0].stats.inputTokens).toBe(300);
679
+ expect(snap[0].stats.outputTokens).toBe(150);
680
+ });
681
+
682
+ it("recordUsage handles missing fields gracefully", async () => {
683
+ const manager = await createManagerWithAccounts(1);
684
+ manager.recordUsage(0, {});
685
+
686
+ const snap = manager.getAccountsSnapshot();
687
+ expect(snap[0].stats.requests).toBe(1);
688
+ expect(snap[0].stats.inputTokens).toBe(0);
689
+ });
690
+
691
+ it("recordUsage ignores invalid index", async () => {
692
+ const manager = await createManagerWithAccounts(1);
693
+ manager.recordUsage(99, { inputTokens: 100 });
694
+ // Should not throw
695
+ const snap = manager.getAccountsSnapshot();
696
+ expect(snap[0].stats.requests).toBe(0);
697
+ });
698
+
699
+ it("resetStats resets a single account", async () => {
700
+ const manager = await createManagerWithAccounts(2);
701
+ manager.recordUsage(0, { inputTokens: 500, outputTokens: 200 });
702
+ manager.recordUsage(1, { inputTokens: 300, outputTokens: 100 });
703
+
704
+ manager.resetStats(0);
705
+
706
+ const snap = manager.getAccountsSnapshot();
707
+ expect(snap[0].stats.requests).toBe(0);
708
+ expect(snap[0].stats.inputTokens).toBe(0);
709
+ // Account 1 should be untouched
710
+ expect(snap[1].stats.requests).toBe(1);
711
+ expect(snap[1].stats.inputTokens).toBe(300);
712
+ });
713
+
714
+ it("resetStats ignores invalid index", async () => {
715
+ const manager = await createManagerWithAccounts(1);
716
+ manager.recordUsage(0, { inputTokens: 500 });
717
+
718
+ manager.resetStats(99);
719
+
720
+ // Account 0 should be untouched
721
+ const snap = manager.getAccountsSnapshot();
722
+ expect(snap[0].stats.requests).toBe(1);
723
+ expect(snap[0].stats.inputTokens).toBe(500);
724
+ });
725
+
726
+ it("resetStats resets all accounts", async () => {
727
+ const manager = await createManagerWithAccounts(2);
728
+ manager.recordUsage(0, { inputTokens: 500 });
729
+ manager.recordUsage(1, { inputTokens: 300 });
730
+
731
+ manager.resetStats("all");
732
+
733
+ const snap = manager.getAccountsSnapshot();
734
+ expect(snap[0].stats.requests).toBe(0);
735
+ expect(snap[1].stats.requests).toBe(0);
736
+ });
737
+
738
+ it("stats are included in saveToDisk output", async () => {
739
+ const manager = await createManagerWithAccounts(1);
740
+ manager.recordUsage(0, { inputTokens: 100, outputTokens: 50 });
741
+
742
+ await manager.saveToDisk();
743
+
744
+ expect(saveAccounts).toHaveBeenCalledWith(
745
+ expect.objectContaining({
746
+ accounts: expect.arrayContaining([
747
+ expect.objectContaining({
748
+ stats: expect.objectContaining({
749
+ requests: 1,
750
+ inputTokens: 100,
751
+ outputTokens: 50,
752
+ }),
753
+ }),
754
+ ]),
749
755
  }),
750
- }),
751
- ]),
752
- }),
753
- );
754
- });
756
+ expect.anything(),
757
+ );
758
+ });
755
759
  });
756
760
 
757
761
  // ---------------------------------------------------------------------------
@@ -759,256 +763,256 @@ describe("AccountManager usage stats", () => {
759
763
  // ---------------------------------------------------------------------------
760
764
 
761
765
  describe("AccountManager merge-on-save", () => {
762
- beforeEach(() => {
763
- vi.clearAllMocks();
764
- });
765
-
766
- async function createManagerWithAccounts(n = 1) {
767
- mockLoadAccounts.mockResolvedValue(null);
768
- const manager = await AccountManager.load(DEFAULT_CONFIG, {
769
- refresh: "tok-1",
770
- access: "access-1",
771
- expires: Date.now() + 3600_000,
772
- });
773
- for (let i = 2; i <= n; i++) {
774
- manager.addAccount(`tok-${i}`, `access-${i}`, Date.now() + 3600_000, `user${i}@test.com`);
766
+ beforeEach(() => {
767
+ vi.clearAllMocks();
768
+ });
769
+
770
+ async function createManagerWithAccounts(n = 1) {
771
+ mockLoadAccounts.mockResolvedValue(null);
772
+ const manager = await AccountManager.load(DEFAULT_CONFIG, {
773
+ refresh: "tok-1",
774
+ access: "access-1",
775
+ expires: Date.now() + 3600_000,
776
+ });
777
+ for (let i = 2; i <= n; i++) {
778
+ manager.addAccount(`tok-${i}`, `access-${i}`, Date.now() + 3600_000, `user${i}@test.com`);
779
+ }
780
+ // Save once to establish baseline, then clear mocks
781
+ await manager.saveToDisk();
782
+ vi.clearAllMocks();
783
+ return manager;
775
784
  }
776
- // Save once to establish baseline, then clear mocks
777
- await manager.saveToDisk();
778
- vi.clearAllMocks();
779
- return manager;
780
- }
781
-
782
- it("merges stats with disk values on save", async () => {
783
- const manager = await createManagerWithAccounts(1);
784
- const snap = manager.getAccountsSnapshot();
785
- const accountId = snap[0].id;
786
-
787
- // Simulate another instance having written stats to disk
788
- mockLoadAccounts.mockResolvedValue(
789
- makeAccountsData([
790
- {
791
- id: accountId,
792
- refreshToken: "tok-1",
793
- stats: {
794
- requests: 50,
795
- inputTokens: 10000,
796
- outputTokens: 5000,
797
- cacheReadTokens: 1000,
798
- cacheWriteTokens: 500,
799
- lastReset: 1000,
800
- },
801
- },
802
- ]),
803
- );
804
- mockSaveAccounts.mockResolvedValue(undefined);
805
-
806
- // This instance records 3 requests
807
- manager.recordUsage(0, {
808
- inputTokens: 100,
809
- outputTokens: 50,
810
- cacheReadTokens: 10,
811
- cacheWriteTokens: 5,
812
- });
813
- manager.recordUsage(0, { inputTokens: 200, outputTokens: 100 });
814
- manager.recordUsage(0, { inputTokens: 300, outputTokens: 150 });
815
-
816
- await manager.saveToDisk();
817
-
818
- const saved = mockSaveAccounts.mock.calls[0][0];
819
- const stats = saved.accounts[0].stats;
820
-
821
- // Should be disk values + our deltas
822
- expect(stats.requests).toBe(53); // 50 + 3
823
- expect(stats.inputTokens).toBe(10600); // 10000 + 100 + 200 + 300
824
- expect(stats.outputTokens).toBe(5300); // 5000 + 50 + 100 + 150
825
- expect(stats.cacheReadTokens).toBe(1010); // 1000 + 10
826
- expect(stats.cacheWriteTokens).toBe(505); // 500 + 5
827
- expect(stats.lastReset).toBe(1000); // Preserved from disk
828
- });
829
-
830
- it("clears deltas after save", async () => {
831
- const manager = await createManagerWithAccounts(1);
832
- const snap = manager.getAccountsSnapshot();
833
- const accountId = snap[0].id;
834
-
835
- mockLoadAccounts.mockResolvedValue(
836
- makeAccountsData([
837
- {
838
- id: accountId,
839
- refreshToken: "tok-1",
840
- stats: {
841
- requests: 10,
842
- inputTokens: 1000,
843
- outputTokens: 500,
844
- cacheReadTokens: 0,
845
- cacheWriteTokens: 0,
846
- lastReset: 1000,
847
- },
848
- },
849
- ]),
850
- );
851
- mockSaveAccounts.mockResolvedValue(undefined);
852
-
853
- manager.recordUsage(0, { inputTokens: 100 });
854
- await manager.saveToDisk();
855
-
856
- // First save: 10 + 1 = 11 requests
857
- expect(mockSaveAccounts.mock.calls[0][0].accounts[0].stats.requests).toBe(11);
858
-
859
- // Second save with no new usage should write disk values as-is (no delta)
860
- mockLoadAccounts.mockResolvedValue(
861
- makeAccountsData([
862
- {
863
- id: accountId,
864
- refreshToken: "tok-1",
865
- stats: {
866
- requests: 11,
867
- inputTokens: 1100,
868
- outputTokens: 500,
869
- cacheReadTokens: 0,
870
- cacheWriteTokens: 0,
871
- lastReset: 1000,
872
- },
873
- },
874
- ]),
875
- );
876
-
877
- await manager.saveToDisk();
878
- // No delta, so stats should match what's on disk
879
- expect(mockSaveAccounts.mock.calls[1][0].accounts[0].stats.requests).toBe(11);
880
- });
881
-
882
- it("resetStats writes absolute values ignoring disk", async () => {
883
- const manager = await createManagerWithAccounts(1);
884
- const snap = manager.getAccountsSnapshot();
885
- const accountId = snap[0].id;
886
-
887
- // Disk has 100 requests from other instances
888
- mockLoadAccounts.mockResolvedValue(
889
- makeAccountsData([
890
- {
891
- id: accountId,
892
- refreshToken: "tok-1",
893
- stats: {
894
- requests: 100,
895
- inputTokens: 50000,
896
- outputTokens: 20000,
897
- cacheReadTokens: 0,
898
- cacheWriteTokens: 0,
899
- lastReset: 1000,
900
- },
901
- },
902
- ]),
903
- );
904
- mockSaveAccounts.mockResolvedValue(undefined);
905
-
906
- manager.resetStats(0);
907
- await manager.saveToDisk();
908
-
909
- const stats = mockSaveAccounts.mock.calls[0][0].accounts[0].stats;
910
- expect(stats.requests).toBe(0);
911
- expect(stats.inputTokens).toBe(0);
912
- expect(stats.outputTokens).toBe(0);
913
- });
914
-
915
- it("accumulates usage after resetStats correctly", async () => {
916
- const manager = await createManagerWithAccounts(1);
917
- const snap = manager.getAccountsSnapshot();
918
- const accountId = snap[0].id;
919
-
920
- mockLoadAccounts.mockResolvedValue(
921
- makeAccountsData([
922
- {
923
- id: accountId,
924
- refreshToken: "tok-1",
925
- stats: {
926
- requests: 100,
927
- inputTokens: 50000,
928
- outputTokens: 20000,
929
- cacheReadTokens: 0,
930
- cacheWriteTokens: 0,
931
- lastReset: 1000,
932
- },
933
- },
934
- ]),
935
- );
936
- mockSaveAccounts.mockResolvedValue(undefined);
937
-
938
- // Reset then record new usage before saving
939
- manager.resetStats(0);
940
- manager.recordUsage(0, { inputTokens: 200, outputTokens: 100 });
941
-
942
- await manager.saveToDisk();
943
-
944
- const stats = mockSaveAccounts.mock.calls[0][0].accounts[0].stats;
945
- expect(stats.requests).toBe(1); // 0 (reset) + 1
946
- expect(stats.inputTokens).toBe(200); // 0 (reset) + 200
947
- expect(stats.outputTokens).toBe(100); // 0 (reset) + 100
948
- });
949
-
950
- it("falls through to absolute values when disk read fails", async () => {
951
- const manager = await createManagerWithAccounts(1);
952
-
953
- // Disk read fails
954
- mockLoadAccounts.mockRejectedValue(new Error("disk error"));
955
- mockSaveAccounts.mockResolvedValue(undefined);
956
-
957
- manager.recordUsage(0, { inputTokens: 100, outputTokens: 50 });
958
- await manager.saveToDisk();
959
-
960
- // Should write in-memory stats as-is
961
- const stats = mockSaveAccounts.mock.calls[0][0].accounts[0].stats;
962
- expect(stats.requests).toBe(1);
963
- expect(stats.inputTokens).toBe(100);
964
- });
965
-
966
- it("does not let stale in-memory auth overwrite fresher disk auth", async () => {
967
- const manager = await createManagerWithAccounts(1);
968
- const account = manager.getAccountsSnapshot()[0];
969
-
970
- mockLoadAccounts.mockResolvedValue(
971
- makeAccountsData([
972
- {
973
- id: account.id,
974
- refreshToken: "disk-rotated-refresh",
975
- access: "disk-fresh-access",
976
- expires: Date.now() + 9_000_000,
977
- token_updated_at: Date.now() + 10_000,
978
- },
979
- ]),
980
- );
981
-
982
- await manager.saveToDisk();
983
-
984
- const saved = mockSaveAccounts.mock.calls[0][0];
985
- expect(saved.accounts[0].refreshToken).toBe("disk-rotated-refresh");
986
- expect(saved.accounts[0].access).toBe("disk-fresh-access");
987
- expect(saved.accounts[0].token_updated_at).toBeGreaterThan(account.tokenUpdatedAt);
988
- });
989
-
990
- it("matches id-less disk records by addedAt when merging auth freshness", async () => {
991
- const manager = await createManagerWithAccounts(1);
992
- const account = manager.getAccountsSnapshot()[0];
993
-
994
- // Simulate legacy/id-less disk record after token rotation.
995
- mockLoadAccounts.mockResolvedValue(
996
- makeAccountsData([
997
- {
998
- id: undefined,
999
- addedAt: account.addedAt,
1000
- refreshToken: "disk-rotated-no-id",
1001
- access: "disk-fresh-no-id",
1002
- expires: Date.now() + 9_000_000,
1003
- token_updated_at: Date.now() + 10_000,
1004
- },
1005
- ]),
1006
- );
1007
-
1008
- await manager.saveToDisk();
1009
-
1010
- const saved = mockSaveAccounts.mock.calls[0][0];
1011
- expect(saved.accounts[0].refreshToken).toBe("disk-rotated-no-id");
1012
- expect(saved.accounts[0].access).toBe("disk-fresh-no-id");
1013
- });
785
+
786
+ it("merges stats with disk values on save", async () => {
787
+ const manager = await createManagerWithAccounts(1);
788
+ const snap = manager.getAccountsSnapshot();
789
+ const accountId = snap[0].id;
790
+
791
+ // Simulate another instance having written stats to disk
792
+ mockLoadAccounts.mockResolvedValue(
793
+ makeAccountsData([
794
+ {
795
+ id: accountId,
796
+ refreshToken: "tok-1",
797
+ stats: {
798
+ requests: 50,
799
+ inputTokens: 10000,
800
+ outputTokens: 5000,
801
+ cacheReadTokens: 1000,
802
+ cacheWriteTokens: 500,
803
+ lastReset: 1000,
804
+ },
805
+ },
806
+ ]),
807
+ );
808
+ mockSaveAccounts.mockResolvedValue(undefined);
809
+
810
+ // This instance records 3 requests
811
+ manager.recordUsage(0, {
812
+ inputTokens: 100,
813
+ outputTokens: 50,
814
+ cacheReadTokens: 10,
815
+ cacheWriteTokens: 5,
816
+ });
817
+ manager.recordUsage(0, { inputTokens: 200, outputTokens: 100 });
818
+ manager.recordUsage(0, { inputTokens: 300, outputTokens: 150 });
819
+
820
+ await manager.saveToDisk();
821
+
822
+ const saved = mockSaveAccounts.mock.calls[0][0];
823
+ const stats = saved.accounts[0].stats;
824
+
825
+ // Should be disk values + our deltas
826
+ expect(stats.requests).toBe(53); // 50 + 3
827
+ expect(stats.inputTokens).toBe(10600); // 10000 + 100 + 200 + 300
828
+ expect(stats.outputTokens).toBe(5300); // 5000 + 50 + 100 + 150
829
+ expect(stats.cacheReadTokens).toBe(1010); // 1000 + 10
830
+ expect(stats.cacheWriteTokens).toBe(505); // 500 + 5
831
+ expect(stats.lastReset).toBe(1000); // Preserved from disk
832
+ });
833
+
834
+ it("clears deltas after save", async () => {
835
+ const manager = await createManagerWithAccounts(1);
836
+ const snap = manager.getAccountsSnapshot();
837
+ const accountId = snap[0].id;
838
+
839
+ mockLoadAccounts.mockResolvedValue(
840
+ makeAccountsData([
841
+ {
842
+ id: accountId,
843
+ refreshToken: "tok-1",
844
+ stats: {
845
+ requests: 10,
846
+ inputTokens: 1000,
847
+ outputTokens: 500,
848
+ cacheReadTokens: 0,
849
+ cacheWriteTokens: 0,
850
+ lastReset: 1000,
851
+ },
852
+ },
853
+ ]),
854
+ );
855
+ mockSaveAccounts.mockResolvedValue(undefined);
856
+
857
+ manager.recordUsage(0, { inputTokens: 100 });
858
+ await manager.saveToDisk();
859
+
860
+ // First save: 10 + 1 = 11 requests
861
+ expect(mockSaveAccounts.mock.calls[0][0].accounts[0].stats.requests).toBe(11);
862
+
863
+ // Second save with no new usage should write disk values as-is (no delta)
864
+ mockLoadAccounts.mockResolvedValue(
865
+ makeAccountsData([
866
+ {
867
+ id: accountId,
868
+ refreshToken: "tok-1",
869
+ stats: {
870
+ requests: 11,
871
+ inputTokens: 1100,
872
+ outputTokens: 500,
873
+ cacheReadTokens: 0,
874
+ cacheWriteTokens: 0,
875
+ lastReset: 1000,
876
+ },
877
+ },
878
+ ]),
879
+ );
880
+
881
+ await manager.saveToDisk();
882
+ // No delta, so stats should match what's on disk
883
+ expect(mockSaveAccounts.mock.calls[1][0].accounts[0].stats.requests).toBe(11);
884
+ });
885
+
886
+ it("resetStats writes absolute values ignoring disk", async () => {
887
+ const manager = await createManagerWithAccounts(1);
888
+ const snap = manager.getAccountsSnapshot();
889
+ const accountId = snap[0].id;
890
+
891
+ // Disk has 100 requests from other instances
892
+ mockLoadAccounts.mockResolvedValue(
893
+ makeAccountsData([
894
+ {
895
+ id: accountId,
896
+ refreshToken: "tok-1",
897
+ stats: {
898
+ requests: 100,
899
+ inputTokens: 50000,
900
+ outputTokens: 20000,
901
+ cacheReadTokens: 0,
902
+ cacheWriteTokens: 0,
903
+ lastReset: 1000,
904
+ },
905
+ },
906
+ ]),
907
+ );
908
+ mockSaveAccounts.mockResolvedValue(undefined);
909
+
910
+ manager.resetStats(0);
911
+ await manager.saveToDisk();
912
+
913
+ const stats = mockSaveAccounts.mock.calls[0][0].accounts[0].stats;
914
+ expect(stats.requests).toBe(0);
915
+ expect(stats.inputTokens).toBe(0);
916
+ expect(stats.outputTokens).toBe(0);
917
+ });
918
+
919
+ it("accumulates usage after resetStats correctly", async () => {
920
+ const manager = await createManagerWithAccounts(1);
921
+ const snap = manager.getAccountsSnapshot();
922
+ const accountId = snap[0].id;
923
+
924
+ mockLoadAccounts.mockResolvedValue(
925
+ makeAccountsData([
926
+ {
927
+ id: accountId,
928
+ refreshToken: "tok-1",
929
+ stats: {
930
+ requests: 100,
931
+ inputTokens: 50000,
932
+ outputTokens: 20000,
933
+ cacheReadTokens: 0,
934
+ cacheWriteTokens: 0,
935
+ lastReset: 1000,
936
+ },
937
+ },
938
+ ]),
939
+ );
940
+ mockSaveAccounts.mockResolvedValue(undefined);
941
+
942
+ // Reset then record new usage before saving
943
+ manager.resetStats(0);
944
+ manager.recordUsage(0, { inputTokens: 200, outputTokens: 100 });
945
+
946
+ await manager.saveToDisk();
947
+
948
+ const stats = mockSaveAccounts.mock.calls[0][0].accounts[0].stats;
949
+ expect(stats.requests).toBe(1); // 0 (reset) + 1
950
+ expect(stats.inputTokens).toBe(200); // 0 (reset) + 200
951
+ expect(stats.outputTokens).toBe(100); // 0 (reset) + 100
952
+ });
953
+
954
+ it("falls through to absolute values when disk read fails", async () => {
955
+ const manager = await createManagerWithAccounts(1);
956
+
957
+ // Disk read fails
958
+ mockLoadAccounts.mockRejectedValue(new Error("disk error"));
959
+ mockSaveAccounts.mockResolvedValue(undefined);
960
+
961
+ manager.recordUsage(0, { inputTokens: 100, outputTokens: 50 });
962
+ await manager.saveToDisk();
963
+
964
+ // Should write in-memory stats as-is
965
+ const stats = mockSaveAccounts.mock.calls[0][0].accounts[0].stats;
966
+ expect(stats.requests).toBe(1);
967
+ expect(stats.inputTokens).toBe(100);
968
+ });
969
+
970
+ it("does not let stale in-memory auth overwrite fresher disk auth", async () => {
971
+ const manager = await createManagerWithAccounts(1);
972
+ const account = manager.getAccountsSnapshot()[0];
973
+
974
+ mockLoadAccounts.mockResolvedValue(
975
+ makeAccountsData([
976
+ {
977
+ id: account.id,
978
+ refreshToken: "disk-rotated-refresh",
979
+ access: "disk-fresh-access",
980
+ expires: Date.now() + 9_000_000,
981
+ token_updated_at: Date.now() + 10_000,
982
+ },
983
+ ]),
984
+ );
985
+
986
+ await manager.saveToDisk();
987
+
988
+ const saved = mockSaveAccounts.mock.calls[0][0];
989
+ expect(saved.accounts[0].refreshToken).toBe("disk-rotated-refresh");
990
+ expect(saved.accounts[0].access).toBe("disk-fresh-access");
991
+ expect(saved.accounts[0].token_updated_at).toBeGreaterThan(account.tokenUpdatedAt);
992
+ });
993
+
994
+ it("matches id-less disk records by addedAt when merging auth freshness", async () => {
995
+ const manager = await createManagerWithAccounts(1);
996
+ const account = manager.getAccountsSnapshot()[0];
997
+
998
+ // Simulate legacy/id-less disk record after token rotation.
999
+ mockLoadAccounts.mockResolvedValue(
1000
+ makeAccountsData([
1001
+ {
1002
+ id: undefined,
1003
+ addedAt: account.addedAt,
1004
+ refreshToken: "disk-rotated-no-id",
1005
+ access: "disk-fresh-no-id",
1006
+ expires: Date.now() + 9_000_000,
1007
+ token_updated_at: Date.now() + 10_000,
1008
+ },
1009
+ ]),
1010
+ );
1011
+
1012
+ await manager.saveToDisk();
1013
+
1014
+ const saved = mockSaveAccounts.mock.calls[0][0];
1015
+ expect(saved.accounts[0].refreshToken).toBe("disk-rotated-no-id");
1016
+ expect(saved.accounts[0].access).toBe("disk-fresh-no-id");
1017
+ });
1014
1018
  });