opencode-anthropic-fix 0.2.0 → 0.3.0-beta.0

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 (43) hide show
  1. package/README.md +31 -6
  2. package/cli.mjs +1 -0
  3. package/index.mjs +160 -231
  4. package/lib/backoff.mjs +1 -1
  5. package/lib/cc-credentials.mjs +1 -1
  6. package/lib/config.mjs +49 -27
  7. package/lib/mimicry/adapter-input.mjs +671 -0
  8. package/lib/mimicry/context-hint-threshold.mjs +127 -0
  9. package/lib/mimicry/headers.mjs +71 -66
  10. package/lib/mimicry/request-body.mjs +23 -24
  11. package/lib/mimicry/system-prompt.mjs +54 -23
  12. package/lib/mimicry/wire-compat.mjs +305 -0
  13. package/lib/rotation.mjs +16 -0
  14. package/package.json +3 -3
  15. package/lib/account-state.test.mjs +0 -109
  16. package/lib/accounts.test.mjs +0 -912
  17. package/lib/adaptive-thinking-models.test.mjs +0 -30
  18. package/lib/backoff.test.mjs +0 -411
  19. package/lib/cc-credentials.test.mjs +0 -391
  20. package/lib/config.test.mjs +0 -757
  21. package/lib/context-hint-persist.mjs +0 -68
  22. package/lib/diagnose.test.mjs +0 -122
  23. package/lib/env.test.mjs +0 -25
  24. package/lib/haiku-call.test.mjs +0 -72
  25. package/lib/message-transform.test.mjs +0 -173
  26. package/lib/mimicry/cache.test.mjs +0 -112
  27. package/lib/mimicry/headers.test.mjs +0 -50
  28. package/lib/mimicry/models.test.mjs +0 -132
  29. package/lib/mimicry/request-body.test.mjs +0 -34
  30. package/lib/mimicry/request-helpers.test.mjs +0 -155
  31. package/lib/mimicry/response-stream.test.mjs +0 -127
  32. package/lib/mimicry/system-prompt.test.mjs +0 -73
  33. package/lib/oauth.test.mjs +0 -343
  34. package/lib/redact.test.mjs +0 -154
  35. package/lib/refresh-lock.test.mjs +0 -131
  36. package/lib/request-headers.test.mjs +0 -164
  37. package/lib/retry/overload-loop.test.mjs +0 -107
  38. package/lib/rotation.test.mjs +0 -346
  39. package/lib/session-metrics.test.mjs +0 -67
  40. package/lib/storage.test.mjs +0 -696
  41. package/lib/token-economy/microcompact.test.mjs +0 -28
  42. package/lib/token-economy/transforms.test.mjs +0 -192
  43. package/lib/tuning.test.mjs +0 -89
@@ -0,0 +1,305 @@
1
+ /**
2
+ * Adapter from the plugin's host request shape to the shared wire constructor.
3
+ * The logical canonical header order is tested, but order on the fetch wire is
4
+ * NOT promised because the returned HeaderPair array is converted to Headers.
5
+ */
6
+
7
+ import { buildClaudeCodeRequest } from "@tormentalabs/claude-code-wire-compat";
8
+ import { stripStainlessHelperMarkers } from "./headers.mjs";
9
+
10
+ const PROFILE_OVERRIDE_ENV = "OPENCODE_ANTHROPIC_PROFILE_OVERRIDE";
11
+
12
+ const BODY_FIELD_NAMES = new Set([
13
+ "model",
14
+ "max_tokens",
15
+ "messages",
16
+ "system",
17
+ "tools",
18
+ "thinking",
19
+ "effort",
20
+ "metadata",
21
+ "context_management",
22
+ "output_config",
23
+ "speed",
24
+ "service_tier",
25
+ "output_format",
26
+ "tool_choice",
27
+ "top_p",
28
+ "top_k",
29
+ "stop_sequences",
30
+ "stream",
31
+ "temperature",
32
+ ]);
33
+
34
+ /**
35
+ * @param {Record<string, import('@tormentalabs/claude-code-wire-compat').JsonValue>} body
36
+ * @returns {Record<string, import('@tormentalabs/claude-code-wire-compat').JsonValue> | undefined}
37
+ */
38
+ function collectExperimentalBodyFields(body) {
39
+ const fields = Object.fromEntries(Object.entries(body).filter(([name]) => !BODY_FIELD_NAMES.has(name)));
40
+ return Object.keys(fields).length > 0 ? fields : undefined;
41
+ }
42
+
43
+ /**
44
+ * The shared contract accepts `system` as an array of strings or text blocks.
45
+ * A host may send a bare string instead, which must be wrapped rather than
46
+ * dropped: silently discarding it would lose caller intent with no error.
47
+ *
48
+ * @param {import('@tormentalabs/claude-code-wire-compat').JsonValue | undefined} system
49
+ * @returns {readonly import('@tormentalabs/claude-code-wire-compat').SystemInput[] | undefined}
50
+ */
51
+ function normalizeSystem(system) {
52
+ if (system === undefined) return undefined;
53
+ if (typeof system === "string") return [system];
54
+ if (Array.isArray(system)) return system;
55
+ throw new TypeError("Expected request system to be a string or an array");
56
+ }
57
+
58
+ /**
59
+ * Rewrite DOTTED model version separators to DASHED ones before the id reaches
60
+ * the shared package.
61
+ *
62
+ * WHY THIS EXISTS. The package classifies models by dashed id ONLY:
63
+ * `normalizeModelId` (dist/model-identity.js) tests `includes("claude-opus-4-7")`
64
+ * and never the dotted spelling, because the genuine Claude Code client emits
65
+ * dashed ids. The plugin, by contrast, deliberately TOLERATES dotted ids
66
+ * throughout — `lib/mimicry/models.mjs` matches with the `[._-]` character class
67
+ * — so a host can legitimately hand us `claude-opus-4.7`.
68
+ *
69
+ * Passing a dotted id straight through MISCLASSIFIES it. For
70
+ * `claude-opus-4.7` no `includes` branch matches, so the fallback
71
+ * `/claude-opus-4(?!-\d(?!\d))/` matches (the character after `claude-opus-4`
72
+ * is `.`, not `-`) and the model resolves to `claude-opus-4-0`. That silently
73
+ * drops `adaptiveThinking`, `supportsEffort`, `supportsMaxEffort` and
74
+ * `supportsXhighEffort`, and wrongly turns `supportsTemperature` ON.
75
+ *
76
+ * The tolerance belongs to the plugin, so it is spent here and the package
77
+ * receives the dashed id it is built for. The rewrite is deliberately narrow:
78
+ * a dot is only replaced when it FOLLOWS a letter or digit and PRECEDES a
79
+ * digit, i.e. when it is a version separator (`claude-opus-4.7`,
80
+ * `claude-3.5-sonnet`). A dot before a letter is left alone, so vendor
81
+ * prefixes such as `anthropic.claude-opus-4-6` in a Bedrock ARN survive
82
+ * untouched.
83
+ *
84
+ * NOTE: `wireId = stripModelMarkers(model)` derives from this same string, so
85
+ * the `model` sent on the wire also becomes dashed. That is intended — the
86
+ * real API only accepts the dashed spelling — and is pinned by a test.
87
+ *
88
+ * @param {import('@tormentalabs/claude-code-wire-compat').JsonValue | undefined} model
89
+ * @returns {import('@tormentalabs/claude-code-wire-compat').JsonValue | undefined}
90
+ */
91
+ function normalizeModelVersionSeparators(model) {
92
+ if (typeof model !== "string") return model;
93
+ return model.replace(/([A-Za-z0-9])\.(?=\d)/g, "$1-");
94
+ }
95
+
96
+ /**
97
+ * Reject a malformed `tools` value instead of dropping it, so a host mistake
98
+ * surfaces as an error rather than as a silently toolless request.
99
+ *
100
+ * @param {import('@tormentalabs/claude-code-wire-compat').JsonValue | undefined} tools
101
+ * @returns {readonly import('@tormentalabs/claude-code-wire-compat').ToolDefinition[] | undefined}
102
+ */
103
+ function normalizeTools(tools) {
104
+ if (tools === undefined) return undefined;
105
+ if (Array.isArray(tools)) return tools;
106
+ throw new TypeError("Expected request tools to be an array");
107
+ }
108
+
109
+ /**
110
+ * Resolve the emergency protocol-profile override. Explicit plugin
111
+ * configuration wins over the environment; an absent override leaves the
112
+ * shared package's pinned profile untouched. JSON and package validation
113
+ * errors intentionally propagate so an emergency override cannot fail open.
114
+ *
115
+ * @param {import('@tormentalabs/claude-code-wire-compat').ClaudeCodeProfileOverride | undefined} configuredOverride
116
+ * @returns {import('@tormentalabs/claude-code-wire-compat').ClaudeCodeProfileOverride | undefined}
117
+ */
118
+ function resolveProfileOverride(configuredOverride) {
119
+ if (configuredOverride !== undefined) return configuredOverride;
120
+
121
+ const serializedOverride = process.env[PROFILE_OVERRIDE_ENV];
122
+ if (serializedOverride === undefined || serializedOverride === "") return undefined;
123
+
124
+ const parsed = JSON.parse(serializedOverride);
125
+ if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
126
+ throw new TypeError(`${PROFILE_OVERRIDE_ENV} must contain a JSON object`);
127
+ }
128
+ return parsed;
129
+ }
130
+
131
+ /**
132
+ * @param {Record<string, import('@tormentalabs/claude-code-wire-compat').JsonValue>} body
133
+ * @param {{
134
+ * accessToken: string,
135
+ * clientRequestId: string,
136
+ * runtime: import('@tormentalabs/claude-code-wire-compat').ClaudeCodeRuntimeIdentity,
137
+ * app?: 'cli' | 'cli-bg',
138
+ * stainlessRetryCount?: number,
139
+ * stainlessHelper?: string,
140
+ * claudeRemoteContainerId?: string,
141
+ * claudeRemoteSessionId?: string,
142
+ * clientApp?: string,
143
+ * anthropicAdditionalProtection?: string,
144
+ * extraHeaders?: readonly import('@tormentalabs/claude-code-wire-compat').HeaderPair[],
145
+ * extraHeaderPolicy?: import('@tormentalabs/claude-code-wire-compat').ClaudeCodeExtraHeaderPolicy,
146
+ * additionalBetas?: readonly string[],
147
+ * suppressBetas?: readonly string[],
148
+ * suppressBillingBlock?: boolean,
149
+ * suppressIdentityBlock?: boolean,
150
+ * betaOverrides?: import('@tormentalabs/claude-code-wire-compat').ClaudeCodeBetaOverrides,
151
+ * metadataOverrides?: import('@tormentalabs/claude-code-wire-compat').ClaudeCodeMetadataOverrides,
152
+ * cacheControl?: import('@tormentalabs/claude-code-wire-compat').ClaudeCodeCacheControlInput,
153
+ * capabilities?: Partial<import('@tormentalabs/claude-code-wire-compat').ClaudeCodeCapabilities>,
154
+ * profileOverride?: import('@tormentalabs/claude-code-wire-compat').ClaudeCodeProfileOverride,
155
+ * }} transport
156
+ * @returns {import('@tormentalabs/claude-code-wire-compat').ClaudeCodeRequestInput}
157
+ */
158
+ export function toClaudeCodeRequestInput(body, transport) {
159
+ const system = normalizeSystem(body.system);
160
+ const profileOverride = resolveProfileOverride(transport.profileOverride);
161
+ const thinking =
162
+ body.thinking === undefined
163
+ ? undefined
164
+ : {
165
+ type: body.thinking.type,
166
+ ...(body.thinking.budget_tokens === undefined ? {} : { budgetTokens: body.thinking.budget_tokens }),
167
+ ...(body.thinking.display === undefined ? {} : { display: body.thinking.display }),
168
+ };
169
+ const outputConfig =
170
+ body.output_config === undefined
171
+ ? undefined
172
+ : Object.fromEntries(
173
+ Object.entries(body.output_config).map(([name, value]) => [
174
+ name === "max_output_tokens" ? "maxOutputTokens" : name,
175
+ value,
176
+ ]),
177
+ );
178
+ const input = {
179
+ accessToken: transport.accessToken,
180
+ model: normalizeModelVersionSeparators(body.model),
181
+ maxTokens: body.max_tokens,
182
+ messages: body.messages,
183
+ runtime: transport.runtime,
184
+ clientRequestId: transport.clientRequestId,
185
+ system,
186
+ tools: normalizeTools(body.tools),
187
+ thinking,
188
+ cacheControl: transport.cacheControl,
189
+ // WHY: the package derives model capabilities from the normalized model id
190
+ // alone. This seam is the only way a consumer can DOWNGRADE one of them
191
+ // (`build-request.js` rejects an UPGRADE with `UNSUPPORTED_CAPABILITY`, but
192
+ // allows `true -> false`). The plugin needs it so
193
+ // `OPENCODE_ANTHROPIC_DISABLE_ADAPTIVE_THINKING` can turn `adaptiveThinking`
194
+ // off and push `resolveThinking` down its `else` branch, emitting
195
+ // `{budget_tokens, type: "enabled"}` instead of `{type: "adaptive"}`.
196
+ // Omitted entirely when the transport does not set it, so a consumer that
197
+ // downgrades nothing stays byte-identical to the genuine client.
198
+ capabilities: transport.capabilities,
199
+ effort: body.effort,
200
+ metadata: body.metadata,
201
+ experimentalBodyFields: collectExperimentalBodyFields(body),
202
+ contextManagement: body.context_management,
203
+ outputConfig,
204
+ speed: body.speed,
205
+ serviceTier: body.service_tier,
206
+ outputFormat: body.output_format,
207
+ toolChoice: body.tool_choice,
208
+ topP: body.top_p,
209
+ topK: body.top_k,
210
+ stopSequences: body.stop_sequences,
211
+ stream: body.stream,
212
+ temperature: body.temperature,
213
+ app: transport.app,
214
+ stainlessRetryCount: transport.stainlessRetryCount,
215
+ stainlessHelper: transport.stainlessHelper,
216
+ claudeRemoteContainerId: transport.claudeRemoteContainerId,
217
+ claudeRemoteSessionId: transport.claudeRemoteSessionId,
218
+ clientApp: transport.clientApp,
219
+ anthropicAdditionalProtection: transport.anthropicAdditionalProtection,
220
+ extraHeaders: transport.extraHeaders,
221
+ extraHeaderPolicy: transport.extraHeaderPolicy,
222
+ additionalBetas: transport.additionalBetas,
223
+ suppressBetas: transport.suppressBetas,
224
+ suppressBillingBlock: transport.suppressBillingBlock,
225
+ // Sibling of `suppressBillingBlock` at the ROOT of the package input — NOT
226
+ // `cacheControl.suppressIdentityBlock`, which only drops the block's
227
+ // `cache_control` marker and keeps the block itself. This one omits the
228
+ // whole identity block, which is what the lean-system-prompt feature needs
229
+ // now that the adapter path delegates canonical block composition to the
230
+ // package (`suppressCanonicalBlocks` in request-body.mjs).
231
+ suppressIdentityBlock: transport.suppressIdentityBlock,
232
+ // UNCONDITIONAL on purpose. `lib/mimicry/request-body.mjs` never touches
233
+ // `cache_control` on `thinking`/`redacted_thinking` blocks because ANY
234
+ // mutation of those blocks — including deleting the key — makes the real
235
+ // API answer 400 "thinking or redacted_thinking blocks in the latest
236
+ // assistant message cannot be modified". The plugin therefore ALWAYS needs
237
+ // the package to accept and pass the key through verbatim; the need is a
238
+ // property of the API round-trip contract, not of a given request. Gating
239
+ // it on "does some block actually carry the key" would add a scan and a
240
+ // second state to get wrong for zero behavioural gain: with the flag on and
241
+ // no such key present, the package output is byte-identical.
242
+ preserveThinkingBlockCacheControl: true,
243
+ betaOverrides: transport.betaOverrides,
244
+ metadataOverrides: transport.metadataOverrides,
245
+ ...(profileOverride === undefined ? {} : { profileOverride }),
246
+ };
247
+ return Object.fromEntries(Object.entries(input).filter(([, value]) => value !== undefined));
248
+ }
249
+
250
+ /**
251
+ * Build a Claude Code request from the JSON body received from the host.
252
+ *
253
+ * @param {string | undefined} body
254
+ * @param {{
255
+ * accessToken: string,
256
+ * clientRequestId: string,
257
+ * runtime: import('@tormentalabs/claude-code-wire-compat').ClaudeCodeRuntimeIdentity,
258
+ * app?: 'cli' | 'cli-bg',
259
+ * stainlessRetryCount?: number,
260
+ * stainlessHelper?: string,
261
+ * claudeRemoteContainerId?: string,
262
+ * claudeRemoteSessionId?: string,
263
+ * clientApp?: string,
264
+ * anthropicAdditionalProtection?: string,
265
+ * extraHeaders?: readonly import('@tormentalabs/claude-code-wire-compat').HeaderPair[],
266
+ * extraHeaderPolicy?: import('@tormentalabs/claude-code-wire-compat').ClaudeCodeExtraHeaderPolicy,
267
+ * additionalBetas?: readonly string[],
268
+ * suppressBetas?: readonly string[],
269
+ * suppressBillingBlock?: boolean,
270
+ * suppressIdentityBlock?: boolean,
271
+ * betaOverrides?: import('@tormentalabs/claude-code-wire-compat').ClaudeCodeBetaOverrides,
272
+ * metadataOverrides?: import('@tormentalabs/claude-code-wire-compat').ClaudeCodeMetadataOverrides,
273
+ * cacheControl?: import('@tormentalabs/claude-code-wire-compat').ClaudeCodeCacheControlInput,
274
+ * capabilities?: Partial<import('@tormentalabs/claude-code-wire-compat').ClaudeCodeCapabilities>,
275
+ * profileOverride?: import('@tormentalabs/claude-code-wire-compat').ClaudeCodeProfileOverride,
276
+ * }} transport
277
+ * @returns {Promise<Omit<import('@tormentalabs/claude-code-wire-compat').BuiltClaudeCodeRequest, 'headers'> & {headers: Headers}>}
278
+ */
279
+ export async function buildWireCompatibleRequest(body, transport) {
280
+ if (!body || typeof body !== "string") throw new TypeError("Expected a JSON request body");
281
+ const parsed = JSON.parse(body);
282
+ // The stainless helper markers are an internal signal: buildAdapterTransport
283
+ // already derived `transport.stainlessHelper` from them, and the API has never
284
+ // known those keys — the package rejects them with INVALID_INPUT. `parsed` is
285
+ // a local object owned by this call, so the strip is in place.
286
+ stripStainlessHelperMarkers(parsed?.tools, parsed?.messages);
287
+ let built;
288
+ try {
289
+ built = await buildClaudeCodeRequest(toClaudeCodeRequestInput(parsed, transport));
290
+ } catch (error) {
291
+ // The package deliberately sanitizes its errors down to a bare code, and
292
+ // carries the useful part in `safeDetails`, which nothing surfaces. Fold it
293
+ // into the message so a rejection is diagnosable from a stack trace alone.
294
+ // The error object itself is preserved, so `code`, `safeDetails` and
295
+ // `instanceof` all still hold for callers that inspect them.
296
+ if (error instanceof Error && "safeDetails" in error) {
297
+ const detail = Object.entries(error.safeDetails ?? {})
298
+ .map(([key, value]) => `${key}=${value}`)
299
+ .join(" ");
300
+ if (detail) error.message = `${error.message} (${detail})`;
301
+ }
302
+ throw error;
303
+ }
304
+ return { ...built, headers: new Headers(built.headers) };
305
+ }
package/lib/rotation.mjs CHANGED
@@ -259,6 +259,22 @@ export function selectAccount(candidates, strategy, currentIndex, healthTracker,
259
259
  return next ? { index: next.index, cursor: cursor + 1 } : null;
260
260
  }
261
261
 
262
+ case "single": {
263
+ // Single-account mode: use exactly one account and never auto-rotate.
264
+ // Stay on the current account while it is available; if it is
265
+ // rate-limited or otherwise unavailable, STOP (return null) so the
266
+ // caller alerts the user instead of silently switching accounts.
267
+ // Auto-resumes once the account becomes available again (e.g. the
268
+ // rate-limit window expires). See issue #20.
269
+ if (currentIndex !== null) {
270
+ const current = available.find((acc) => acc.index === currentIndex);
271
+ return current ? { index: current.index, cursor } : null;
272
+ }
273
+ // No account selected yet — pick the first available as the single account.
274
+ const next = available[cursor % available.length];
275
+ return next ? { index: next.index, cursor: cursor + 1 } : null;
276
+ }
277
+
262
278
  case "hybrid": {
263
279
  const scoredCandidates = available
264
280
  .filter((acc) => healthTracker.isUsable(acc.index) && tokenTracker.hasTokens(acc.index))
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-anthropic-fix",
3
- "version": "0.2.0",
3
+ "version": "0.3.0-beta.0",
4
4
  "license": "GPL-3.0-or-later",
5
5
  "repository": {
6
6
  "type": "git",
@@ -15,6 +15,7 @@
15
15
  "index.mjs",
16
16
  "cli.mjs",
17
17
  "lib/**/*.mjs",
18
+ "!lib/**/*.test.mjs",
18
19
  "README.md",
19
20
  "LICENSE"
20
21
  ],
@@ -54,7 +55,6 @@
54
55
  "vitest": "^4.0.18"
55
56
  },
56
57
  "dependencies": {
57
- "@openauthjs/openauth": "^0.4.3",
58
- "xxhash-wasm": "^1.1.0"
58
+ "@tormentalabs/claude-code-wire-compat": "0.1.0"
59
59
  }
60
60
  }
@@ -1,109 +0,0 @@
1
- import { describe, it, expect } from "vitest";
2
- import { applyOAuthCredentials, resetAccountTracking, adjustActiveIndexAfterRemoval } from "./account-state.mjs";
3
-
4
- describe("resetAccountTracking", () => {
5
- it("resets rate-limit and failure fields", () => {
6
- const account = {
7
- rateLimitResetTimes: { anthropic: Date.now() + 60_000 },
8
- consecutiveFailures: 7,
9
- lastFailureTime: Date.now(),
10
- };
11
-
12
- resetAccountTracking(account);
13
-
14
- expect(account.rateLimitResetTimes).toEqual({});
15
- expect(account.consecutiveFailures).toBe(0);
16
- expect(account.lastFailureTime).toBeNull();
17
- });
18
- });
19
-
20
- describe("applyOAuthCredentials", () => {
21
- it("applies refresh/access/expiry and optional email", () => {
22
- const account = {
23
- refreshToken: "old-refresh",
24
- access: "old-access",
25
- expires: 1,
26
- email: "old@example.com",
27
- };
28
-
29
- applyOAuthCredentials(account, {
30
- refresh: "new-refresh",
31
- access: "new-access",
32
- expires: 123,
33
- email: "new@example.com",
34
- });
35
-
36
- expect(account).toEqual({
37
- refreshToken: "new-refresh",
38
- access: "new-access",
39
- expires: 123,
40
- token_updated_at: expect.any(Number),
41
- email: "new@example.com",
42
- });
43
- });
44
-
45
- it("preserves existing email when credentials omit email", () => {
46
- const account = {
47
- refreshToken: "old-refresh",
48
- access: "old-access",
49
- expires: 1,
50
- email: "old@example.com",
51
- };
52
-
53
- applyOAuthCredentials(account, {
54
- refresh: "new-refresh",
55
- access: "new-access",
56
- expires: 456,
57
- });
58
-
59
- expect(account.email).toBe("old@example.com");
60
- expect(account.refreshToken).toBe("new-refresh");
61
- expect(account.access).toBe("new-access");
62
- expect(account.expires).toBe(456);
63
- expect(account.token_updated_at).toEqual(expect.any(Number));
64
- });
65
- });
66
-
67
- describe("adjustActiveIndexAfterRemoval", () => {
68
- it("resets activeIndex to 0 when no accounts remain", () => {
69
- const storage = { accounts: [], activeIndex: 3 };
70
- adjustActiveIndexAfterRemoval(storage, 0);
71
- expect(storage.activeIndex).toBe(0);
72
- });
73
-
74
- it("clamps activeIndex when it falls out of range", () => {
75
- const storage = {
76
- accounts: [{ id: "a" }, { id: "b" }],
77
- activeIndex: 2,
78
- };
79
- adjustActiveIndexAfterRemoval(storage, 0);
80
- expect(storage.activeIndex).toBe(1);
81
- });
82
-
83
- it("decrements activeIndex when removed index is before active", () => {
84
- const storage = {
85
- accounts: [{ id: "a" }, { id: "b" }, { id: "c" }],
86
- activeIndex: 2,
87
- };
88
- adjustActiveIndexAfterRemoval(storage, 0);
89
- expect(storage.activeIndex).toBe(1);
90
- });
91
-
92
- it("keeps activeIndex when removed index is after active", () => {
93
- const storage = {
94
- accounts: [{ id: "a" }, { id: "b" }, { id: "c" }],
95
- activeIndex: 0,
96
- };
97
- adjustActiveIndexAfterRemoval(storage, 2);
98
- expect(storage.activeIndex).toBe(0);
99
- });
100
-
101
- it("keeps activeIndex when removed index was the active slot", () => {
102
- const storage = {
103
- accounts: [{ id: "a" }, { id: "b" }, { id: "c" }],
104
- activeIndex: 1,
105
- };
106
- adjustActiveIndexAfterRemoval(storage, 1);
107
- expect(storage.activeIndex).toBe(1);
108
- });
109
- });