opencode-anthropic-fix 0.2.0 → 0.2.1
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.
- package/README.md +7 -6
- package/cli.mjs +1 -0
- package/index.mjs +5 -82
- package/lib/backoff.mjs +1 -1
- package/lib/cc-credentials.mjs +1 -1
- package/lib/config.mjs +2 -2
- package/lib/config.test.mjs +7 -0
- package/lib/mimicry/headers.mjs +0 -2
- package/lib/mimicry/request-body.mjs +0 -2
- package/lib/rotation.mjs +16 -0
- package/lib/rotation.test.mjs +46 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -365,11 +365,12 @@ Pending slash OAuth flows expire after 10 minutes. If completion fails with an e
|
|
|
365
365
|
|
|
366
366
|
Control how the plugin picks which account to use for each request.
|
|
367
367
|
|
|
368
|
-
| Strategy | Behavior
|
|
369
|
-
| ---------------------- |
|
|
370
|
-
| **`sticky`** (default) | Stay on one account until it fails or is rate-limited
|
|
371
|
-
| **`round-robin`** | Rotate through accounts on every request
|
|
372
|
-
| **`hybrid`** | Score-based selection with stickiness bias. Considers health, token budget, and freshness
|
|
368
|
+
| Strategy | Behavior | Best For |
|
|
369
|
+
| ---------------------- | ------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------ |
|
|
370
|
+
| **`sticky`** (default) | Stay on one account until it fails or is rate-limited | Single account, predictable behavior, full feature compatibility |
|
|
371
|
+
| **`round-robin`** | Rotate through accounts on every request | Spreading load evenly across accounts (see [limitations](#round-robin-limitations)) |
|
|
372
|
+
| **`hybrid`** | Score-based selection with stickiness bias. Considers health, token budget, and freshness | Multiple accounts with varying rate limits |
|
|
373
|
+
| **`single`** | Use one account only; stop and alert when it fails or is rate-limited (never auto-switches) | Simple multi-account setups (e.g. work vs personal) where requests must never mix accounts |
|
|
373
374
|
|
|
374
375
|
### Change Strategy
|
|
375
376
|
|
|
@@ -413,7 +414,7 @@ Configuration is stored at `~/.config/opencode/anthropic-auth.json`. All setting
|
|
|
413
414
|
|
|
414
415
|
```jsonc
|
|
415
416
|
{
|
|
416
|
-
// Account selection strategy: "sticky" | "round-robin" | "hybrid"
|
|
417
|
+
// Account selection strategy: "sticky" | "round-robin" | "hybrid" | "single"
|
|
417
418
|
"account_selection_strategy": "sticky",
|
|
418
419
|
|
|
419
420
|
// Seconds before consecutive failure count resets (60-7200)
|
package/cli.mjs
CHANGED
|
@@ -1153,6 +1153,7 @@ export async function cmdStrategy(arg) {
|
|
|
1153
1153
|
sticky: "Stay on one account until it fails or is rate-limited",
|
|
1154
1154
|
"round-robin": "Rotate through accounts on every request",
|
|
1155
1155
|
hybrid: "Prefer healthy accounts, rotate when degraded",
|
|
1156
|
+
single: "Use one account only; stop and alert when it fails or is rate-limited",
|
|
1156
1157
|
};
|
|
1157
1158
|
|
|
1158
1159
|
for (const s of VALID_STRATEGIES) {
|
package/index.mjs
CHANGED
|
@@ -15,15 +15,11 @@ import {
|
|
|
15
15
|
FALLBACK_CLAUDE_CLI_VERSION,
|
|
16
16
|
CLAUDE_CODE_NPM_LATEST_URL,
|
|
17
17
|
CLAUDE_CODE_BUILD_TIME,
|
|
18
|
-
EXPERIMENTAL_BETA_FLAGS,
|
|
19
|
-
BETA_SHORTCUTS,
|
|
20
18
|
resolveBetaShortcut,
|
|
21
|
-
buildExtendedUserAgent,
|
|
22
19
|
} from "./lib/request-headers.mjs";
|
|
23
|
-
import { loadConfig, loadConfigFresh, saveConfig,
|
|
20
|
+
import { loadConfig, loadConfigFresh, saveConfig, getConfigDir } from "./lib/config.mjs";
|
|
24
21
|
import { loadContextHintDisabledFlag, saveContextHintDisabledFlag } from "./lib/context-hint-persist.mjs";
|
|
25
22
|
import { loadAccounts, saveAccounts, clearAccounts, createDefaultStats } from "./lib/storage.mjs";
|
|
26
|
-
import { applyOAuthCredentials, resetAccountTracking } from "./lib/account-state.mjs";
|
|
27
23
|
import { acquireRefreshLock, releaseRefreshLock } from "./lib/refresh-lock.mjs";
|
|
28
24
|
import {
|
|
29
25
|
isAccountSpecificError,
|
|
@@ -51,7 +47,7 @@ import {
|
|
|
51
47
|
stripMcpPrefixFromParsedEvent,
|
|
52
48
|
} from "./lib/mimicry/response-stream.mjs";
|
|
53
49
|
import { transformRequestBody, CORE_TOOL_NAMES } from "./lib/mimicry/request-body.mjs";
|
|
54
|
-
import {
|
|
50
|
+
import { isTruthyEnv } from "./lib/env.mjs";
|
|
55
51
|
import { sessionMetrics, createInitialSessionMetrics, getAverageCacheHitRate } from "./lib/session-metrics.mjs";
|
|
56
52
|
import { repairOrphanedToolUseBlocks } from "./lib/mimicry/request-helpers.mjs";
|
|
57
53
|
import { resolveCacheTtl, shouldPlaceToolBreakpoint, updateBoundaryStability } from "./lib/mimicry/cache.mjs";
|
|
@@ -83,13 +79,11 @@ import {
|
|
|
83
79
|
SUBAGENT_CC_ANCHOR,
|
|
84
80
|
} from "./lib/mimicry/system-prompt.mjs";
|
|
85
81
|
import {
|
|
86
|
-
CLAUDE_3_MODEL_RE,
|
|
87
82
|
hasOneMillionContext,
|
|
88
83
|
isEligibleFor1MContext,
|
|
89
84
|
isOpus46Model,
|
|
90
85
|
isOpus47Model,
|
|
91
86
|
isOpus48Model,
|
|
92
|
-
isSonnet46Model,
|
|
93
87
|
} from "./lib/mimicry/models.mjs";
|
|
94
88
|
|
|
95
89
|
export { isFable5Model, isMythos5Model, isAdaptiveThinkingModel } from "./lib/mimicry/models.mjs";
|
|
@@ -241,7 +235,7 @@ async function runHaikuSessionSummarize(
|
|
|
241
235
|
}
|
|
242
236
|
}
|
|
243
237
|
|
|
244
|
-
export async function AnthropicAuthPlugin({ client
|
|
238
|
+
export async function AnthropicAuthPlugin({ client }) {
|
|
245
239
|
const config = loadConfig();
|
|
246
240
|
_pluginConfig = config; // expose to module-level functions (cache stats, response headers)
|
|
247
241
|
// QA fix H6: read emulation settings live from config instead of stale const capture
|
|
@@ -1191,7 +1185,7 @@ export async function AnthropicAuthPlugin({ client, project, directory, worktree
|
|
|
1191
1185
|
config.toasts.quiet = enabled;
|
|
1192
1186
|
},
|
|
1193
1187
|
strategy: () => {
|
|
1194
|
-
const valid = ["sticky", "round-robin", "hybrid"];
|
|
1188
|
+
const valid = ["sticky", "round-robin", "hybrid", "single"];
|
|
1195
1189
|
if (valid.includes(value)) {
|
|
1196
1190
|
saveConfig({ account_selection_strategy: value });
|
|
1197
1191
|
strategyState.manualOverride = true;
|
|
@@ -5576,7 +5570,6 @@ process.once("beforeExit", _beforeExitHandler);
|
|
|
5576
5570
|
// The compiled Bun binary computes cch dynamically: xxHash64(body, seed) & 0xFFFFF.
|
|
5577
5571
|
// Captured real CC v2.1.107 request shows cch=6d00f (5-hex-char, 20-bit masked hash).
|
|
5578
5572
|
// Seed extracted from binary: 0x6E52736AC806831E (unchanged since v2.1.96).
|
|
5579
|
-
const CCH_SEED = 0x6e52736ac806831en; // BigInt — Attestation.zig seed
|
|
5580
5573
|
|
|
5581
5574
|
/** @type {null | ((buf: Uint8Array, seed: bigint) => bigint)} */
|
|
5582
5575
|
let _xxh64Raw = null;
|
|
@@ -5584,22 +5577,6 @@ const _xxhashReady = xxhashInit().then((h) => {
|
|
|
5584
5577
|
_xxh64Raw = h.h64Raw;
|
|
5585
5578
|
});
|
|
5586
5579
|
|
|
5587
|
-
/**
|
|
5588
|
-
* Compute and replace the cch=00000 placeholder in the serialized body with
|
|
5589
|
-
* xxHash64(body, seed) & 0xFFFFF, matching the Bun binary's native attestation.
|
|
5590
|
-
* @param {string} body - Serialized JSON body
|
|
5591
|
-
* @returns {Promise<string>} Body with cch replaced
|
|
5592
|
-
*/
|
|
5593
|
-
async function computeAndReplaceCCH(body) {
|
|
5594
|
-
if (typeof body !== "string" || !body.includes("cch=00000")) return body;
|
|
5595
|
-
await _xxhashReady;
|
|
5596
|
-
if (!_xxh64Raw) return body; // fallback: send as-is if wasm failed to load
|
|
5597
|
-
const bodyBytes = Buffer.from(body, "utf-8");
|
|
5598
|
-
const hash = _xxh64Raw(bodyBytes, CCH_SEED);
|
|
5599
|
-
const cch = (hash & 0xfffffn).toString(16).padStart(5, "0");
|
|
5600
|
-
return body.replace("cch=00000", `cch=${cch}`);
|
|
5601
|
-
}
|
|
5602
|
-
|
|
5603
5580
|
/**
|
|
5604
5581
|
* Extract the text content of the first user message for billing hash computation.
|
|
5605
5582
|
* @param {any[] | undefined} messages
|
|
@@ -5700,13 +5677,6 @@ function shouldDebugSystemPrompt() {
|
|
|
5700
5677
|
* @returns {Record<string, string>}
|
|
5701
5678
|
*/
|
|
5702
5679
|
|
|
5703
|
-
/**
|
|
5704
|
-
* @returns {string}
|
|
5705
|
-
*/
|
|
5706
|
-
function getClaudeEntrypoint() {
|
|
5707
|
-
return process.env.CLAUDE_CODE_ENTRYPOINT || "cli";
|
|
5708
|
-
}
|
|
5709
|
-
|
|
5710
5680
|
/**
|
|
5711
5681
|
* @param {string | undefined} model
|
|
5712
5682
|
* @returns {string | undefined}
|
|
@@ -5733,15 +5703,6 @@ function logTransformedSystemPrompt(body) {
|
|
|
5733
5703
|
* @returns {boolean}
|
|
5734
5704
|
*/
|
|
5735
5705
|
|
|
5736
|
-
/**
|
|
5737
|
-
* @param {string | undefined} body
|
|
5738
|
-
* @returns {boolean}
|
|
5739
|
-
*/
|
|
5740
|
-
function supportsThinking(model) {
|
|
5741
|
-
if (!model) return true;
|
|
5742
|
-
return /claude|sonnet|opus|haiku/i.test(model);
|
|
5743
|
-
}
|
|
5744
|
-
|
|
5745
5706
|
/**
|
|
5746
5707
|
* @param {string | undefined} body
|
|
5747
5708
|
* @returns {boolean}
|
|
@@ -5830,19 +5791,6 @@ function getAccountIdentifier(account) {
|
|
|
5830
5791
|
* @returns {{user_id: string}}
|
|
5831
5792
|
*/
|
|
5832
5793
|
|
|
5833
|
-
/**
|
|
5834
|
-
* Return the cache_control object appropriate for the given cache policy.
|
|
5835
|
-
* @param {{ttl: string, ttl_supported: boolean, boundary_marker?: boolean} | undefined} cachePolicy
|
|
5836
|
-
* @returns {{type: string, ttl?: string}}
|
|
5837
|
-
*/
|
|
5838
|
-
function getCacheControlForPolicy(cachePolicy) {
|
|
5839
|
-
if (!cachePolicy) return { type: "ephemeral" };
|
|
5840
|
-
if (cachePolicy.ttl === "off" || cachePolicy.ttl_supported === false) {
|
|
5841
|
-
return { type: "ephemeral" };
|
|
5842
|
-
}
|
|
5843
|
-
return { type: "ephemeral", ttl: cachePolicy.ttl };
|
|
5844
|
-
}
|
|
5845
|
-
|
|
5846
5794
|
/**
|
|
5847
5795
|
* @param {string} incomingBeta
|
|
5848
5796
|
* @param {boolean} signatureEnabled
|
|
@@ -5866,18 +5814,6 @@ function getCacheControlForPolicy(cachePolicy) {
|
|
|
5866
5814
|
|
|
5867
5815
|
/** @typedef {'low' | 'medium' | 'high'} ThinkingEffort */
|
|
5868
5816
|
|
|
5869
|
-
/**
|
|
5870
|
-
* Map budgetTokens to an effort level.
|
|
5871
|
-
* Used when an Opus 4.6 request arrives with the legacy budgetTokens shape.
|
|
5872
|
-
* @param {number} budgetTokens
|
|
5873
|
-
* @returns {ThinkingEffort}
|
|
5874
|
-
*/
|
|
5875
|
-
function budgetTokensToEffort(budgetTokens) {
|
|
5876
|
-
if (budgetTokens <= 1024) return "low";
|
|
5877
|
-
if (budgetTokens <= 8000) return "medium";
|
|
5878
|
-
return "high";
|
|
5879
|
-
}
|
|
5880
|
-
|
|
5881
5817
|
// QA fix L3: budgetTokensToEffort() removed — dead code, never called
|
|
5882
5818
|
// QA fix L4: isValidEffort() removed — dead code, never called
|
|
5883
5819
|
|
|
@@ -6068,19 +6004,6 @@ function isEventStreamResponse(response) {
|
|
|
6068
6004
|
return contentType.toLowerCase().includes("text/event-stream");
|
|
6069
6005
|
}
|
|
6070
6006
|
|
|
6071
|
-
/**
|
|
6072
|
-
* Build user-facing switch reason text for account-specific errors.
|
|
6073
|
-
* @param {number} status
|
|
6074
|
-
* @param {import('./lib/backoff.mjs').RateLimitReason} reason
|
|
6075
|
-
* @returns {string}
|
|
6076
|
-
*/
|
|
6077
|
-
function formatSwitchReason(status, reason) {
|
|
6078
|
-
if (reason === "AUTH_FAILED") return "auth failed";
|
|
6079
|
-
if (status === 403 && reason === "QUOTA_EXHAUSTED") return "permission denied";
|
|
6080
|
-
if (reason === "QUOTA_EXHAUSTED") return "quota exhausted";
|
|
6081
|
-
return "rate-limited";
|
|
6082
|
-
}
|
|
6083
|
-
|
|
6084
6007
|
// ---------------------------------------------------------------------------
|
|
6085
6008
|
// Token refresh (per-account)
|
|
6086
6009
|
// ---------------------------------------------------------------------------
|
|
@@ -6153,7 +6076,7 @@ function applyDiskAuthIfFresher(account, diskAuth, options = {}) {
|
|
|
6153
6076
|
* @returns {Promise<string>} The new access token
|
|
6154
6077
|
* @throws {Error} If refresh fails
|
|
6155
6078
|
*/
|
|
6156
|
-
async function refreshAccountToken(account, client,
|
|
6079
|
+
async function refreshAccountToken(account, client, _source = "foreground", { onTokensUpdated } = {}) {
|
|
6157
6080
|
// CC-sourced accounts must NEVER enter the OAuth HTTP refresh flow.
|
|
6158
6081
|
// Instead, re-read credentials from the CC source. If they're still
|
|
6159
6082
|
// expired, let the caller handle it (the account will be skipped).
|
package/lib/backoff.mjs
CHANGED
|
@@ -134,7 +134,7 @@ function extractErrorSignals(body) {
|
|
|
134
134
|
* @returns {boolean}
|
|
135
135
|
*/
|
|
136
136
|
function bodyHasAccountError(body) {
|
|
137
|
-
const { errorType, message
|
|
137
|
+
const { errorType, message } = extractErrorSignals(body);
|
|
138
138
|
|
|
139
139
|
const typeSignals = [
|
|
140
140
|
"rate_limit",
|
package/lib/cc-credentials.mjs
CHANGED
package/lib/config.mjs
CHANGED
|
@@ -4,7 +4,7 @@ import { homedir } from "node:os";
|
|
|
4
4
|
import { randomBytes } from "node:crypto";
|
|
5
5
|
|
|
6
6
|
/**
|
|
7
|
-
* @typedef {'sticky' | 'round-robin' | 'hybrid'} AccountSelectionStrategy
|
|
7
|
+
* @typedef {'sticky' | 'round-robin' | 'hybrid' | 'single'} AccountSelectionStrategy
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
10
|
/**
|
|
@@ -449,7 +449,7 @@ export const DEFAULT_CONFIG = {
|
|
|
449
449
|
},
|
|
450
450
|
};
|
|
451
451
|
|
|
452
|
-
export const VALID_STRATEGIES = ["sticky", "round-robin", "hybrid"];
|
|
452
|
+
export const VALID_STRATEGIES = ["sticky", "round-robin", "hybrid", "single"];
|
|
453
453
|
|
|
454
454
|
/** OpenCode's OAuth client ID for Anthropic console auth flows. */
|
|
455
455
|
export const CLIENT_ID = "9d1c250a-e61b-44d9-88ed-5944d1962f5e";
|
package/lib/config.test.mjs
CHANGED
|
@@ -138,6 +138,13 @@ describe("loadConfig", () => {
|
|
|
138
138
|
expect(config.account_selection_strategy).toBe("sticky");
|
|
139
139
|
});
|
|
140
140
|
|
|
141
|
+
it("accepts 'single' strategy from config file", () => {
|
|
142
|
+
existsSync.mockReturnValue(true);
|
|
143
|
+
readFileSync.mockReturnValue(JSON.stringify({ account_selection_strategy: "single" }));
|
|
144
|
+
const config = loadConfig();
|
|
145
|
+
expect(config.account_selection_strategy).toBe("single");
|
|
146
|
+
});
|
|
147
|
+
|
|
141
148
|
it("ignores invalid strategy", () => {
|
|
142
149
|
existsSync.mockReturnValue(true);
|
|
143
150
|
readFileSync.mockReturnValue(JSON.stringify({ account_selection_strategy: "invalid" }));
|
package/lib/mimicry/headers.mjs
CHANGED
|
@@ -177,8 +177,6 @@ export function buildAnthropicBetaHeader(
|
|
|
177
177
|
return mergedBetas.join(",");
|
|
178
178
|
}
|
|
179
179
|
|
|
180
|
-
const nonInteractive = isNonInteractiveMode();
|
|
181
|
-
const haiku = isHaikuModel(model);
|
|
182
180
|
const isRoundRobin = strategy === "round-robin";
|
|
183
181
|
const te = tokenEconomy || {};
|
|
184
182
|
|
|
@@ -66,8 +66,6 @@ export const CORE_TOOL_NAMES = new Set([
|
|
|
66
66
|
export function transformRequestBody(body, signature, runtime, betaHeader, config) {
|
|
67
67
|
if (!body || typeof body !== "string") return body;
|
|
68
68
|
|
|
69
|
-
const TOOL_PREFIX = "mcp_";
|
|
70
|
-
|
|
71
69
|
try {
|
|
72
70
|
const parsed = JSON.parse(body);
|
|
73
71
|
// Diagnostic: log the incoming model + thinking/speed shape so model-routing
|
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/lib/rotation.test.mjs
CHANGED
|
@@ -337,6 +337,52 @@ describe("selectAccount", () => {
|
|
|
337
337
|
});
|
|
338
338
|
});
|
|
339
339
|
|
|
340
|
+
// Single strategy (issue #20)
|
|
341
|
+
describe("single strategy", () => {
|
|
342
|
+
it("selects first available account when no current index", () => {
|
|
343
|
+
const candidates = [makeCandidate({ index: 0 }), makeCandidate({ index: 1 })];
|
|
344
|
+
const result = selectAccount(candidates, "single", null, healthTracker, tokenTracker, 0);
|
|
345
|
+
expect(result?.index).toBe(0);
|
|
346
|
+
});
|
|
347
|
+
|
|
348
|
+
it("stays on the current account when it is available", () => {
|
|
349
|
+
const candidates = [makeCandidate({ index: 0 }), makeCandidate({ index: 1 })];
|
|
350
|
+
const result = selectAccount(candidates, "single", 0, healthTracker, tokenTracker, 0);
|
|
351
|
+
expect(result?.index).toBe(0);
|
|
352
|
+
expect(result?.cursor).toBe(0);
|
|
353
|
+
});
|
|
354
|
+
|
|
355
|
+
it("returns null when current is rate-limited (does NOT switch)", () => {
|
|
356
|
+
const candidates = [makeCandidate({ index: 0, isRateLimited: true }), makeCandidate({ index: 1 })];
|
|
357
|
+
const result = selectAccount(candidates, "single", 0, healthTracker, tokenTracker, 0);
|
|
358
|
+
// Unlike sticky, single must NOT fall through to account 1.
|
|
359
|
+
expect(result).toBeNull();
|
|
360
|
+
});
|
|
361
|
+
|
|
362
|
+
it("returns null when the current account is gone (disabled/removed)", () => {
|
|
363
|
+
const candidates = [makeCandidate({ index: 1 })];
|
|
364
|
+
const result = selectAccount(candidates, "single", 0, healthTracker, tokenTracker, 0);
|
|
365
|
+
expect(result).toBeNull();
|
|
366
|
+
});
|
|
367
|
+
|
|
368
|
+
it("ignores health score and stays on current", () => {
|
|
369
|
+
for (let i = 0; i < 5; i++) {
|
|
370
|
+
healthTracker.recordFailure(0);
|
|
371
|
+
}
|
|
372
|
+
const candidates = [makeCandidate({ index: 0 }), makeCandidate({ index: 1 })];
|
|
373
|
+
const result = selectAccount(candidates, "single", 0, healthTracker, tokenTracker, 0);
|
|
374
|
+
expect(result?.index).toBe(0);
|
|
375
|
+
});
|
|
376
|
+
|
|
377
|
+
it("auto-resumes the current account once its rate-limit clears", () => {
|
|
378
|
+
const limited = [makeCandidate({ index: 0, isRateLimited: true }), makeCandidate({ index: 1 })];
|
|
379
|
+
expect(selectAccount(limited, "single", 0, healthTracker, tokenTracker, 0)).toBeNull();
|
|
380
|
+
const cleared = [makeCandidate({ index: 0 }), makeCandidate({ index: 1 })];
|
|
381
|
+
const result = selectAccount(cleared, "single", 0, healthTracker, tokenTracker, 0);
|
|
382
|
+
expect(result?.index).toBe(0);
|
|
383
|
+
});
|
|
384
|
+
});
|
|
385
|
+
|
|
340
386
|
// Default/unknown strategy
|
|
341
387
|
it("falls back to first available for unknown strategy", () => {
|
|
342
388
|
const candidates = [makeCandidate({ index: 0 }), makeCandidate({ index: 1 })];
|