opencode-anthropic-fix 0.0.32 → 0.0.33
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/index.mjs +138 -48
- package/lib/accounts.mjs +1 -0
- package/lib/cc-credentials.mjs +2 -2
- package/lib/cc-credentials.test.mjs +29 -15
- package/lib/config.mjs +8 -1
- package/lib/config.test.mjs +3 -3
- package/lib/storage.mjs +4 -0
- package/package.json +1 -1
package/index.mjs
CHANGED
|
@@ -821,7 +821,7 @@ export async function AnthropicAuthPlugin({ client, project, directory, worktree
|
|
|
821
821
|
},
|
|
822
822
|
"adaptive-context": () => {
|
|
823
823
|
const enabled = value === "on" || value === "true" || value === "1";
|
|
824
|
-
saveConfig({ adaptive_context: { enabled } });
|
|
824
|
+
saveConfig({ adaptive_context: { ...config.adaptive_context, enabled } });
|
|
825
825
|
if (!config.adaptive_context)
|
|
826
826
|
config.adaptive_context = {
|
|
827
827
|
enabled: false,
|
|
@@ -833,6 +833,7 @@ export async function AnthropicAuthPlugin({ client, project, directory, worktree
|
|
|
833
833
|
if (!enabled) {
|
|
834
834
|
adaptiveContextState.active = false;
|
|
835
835
|
adaptiveContextState.escalatedByError = false;
|
|
836
|
+
adaptiveContextState.lastTransitionTurn = sessionMetrics.turns;
|
|
836
837
|
}
|
|
837
838
|
},
|
|
838
839
|
};
|
|
@@ -1825,6 +1826,8 @@ export async function AnthropicAuthPlugin({ client, project, directory, worktree
|
|
|
1825
1826
|
let serviceWideRetryCount = 0; // Track 529/503 retries (max 2 per RE doc §5.5)
|
|
1826
1827
|
let shouldRetryCount = 0; // Track x-should-retry forced retries (cap at 3)
|
|
1827
1828
|
let consecutive529Count = 0;
|
|
1829
|
+
let _adaptiveDecisionMade = false; // Ensure adaptive context decision is made only once per logical request
|
|
1830
|
+
let _adaptiveOverrideForRequest; // Cached adaptive override for all retry attempts
|
|
1828
1831
|
for (let attempt = 0; attempt < maxAttempts; attempt++) {
|
|
1829
1832
|
// Select account — use pinned account on first attempt if available
|
|
1830
1833
|
const account =
|
|
@@ -1974,30 +1977,34 @@ export async function AnthropicAuthPlugin({ client, project, directory, worktree
|
|
|
1974
1977
|
);
|
|
1975
1978
|
const _reqProvider = detectProvider(requestUrl);
|
|
1976
1979
|
|
|
1977
|
-
// --- Adaptive 1M context decision ---
|
|
1978
|
-
|
|
1979
|
-
|
|
1980
|
-
|
|
1981
|
-
|
|
1982
|
-
|
|
1983
|
-
|
|
1984
|
-
|
|
1985
|
-
|
|
1986
|
-
|
|
1987
|
-
|
|
1988
|
-
|
|
1989
|
-
|
|
1990
|
-
|
|
1991
|
-
|
|
1992
|
-
|
|
1993
|
-
|
|
1994
|
-
|
|
1995
|
-
|
|
1980
|
+
// --- Adaptive 1M context decision (once per logical request, not per retry) ---
|
|
1981
|
+
if (!_adaptiveDecisionMade) {
|
|
1982
|
+
_adaptiveDecisionMade = true;
|
|
1983
|
+
const _prevAdaptiveState = adaptiveContextState.active;
|
|
1984
|
+
const _use1MContext = resolveAdaptiveContext(
|
|
1985
|
+
requestInit.body,
|
|
1986
|
+
_reqModel,
|
|
1987
|
+
config.adaptive_context || {
|
|
1988
|
+
enabled: true,
|
|
1989
|
+
escalation_threshold: 150_000,
|
|
1990
|
+
deescalation_threshold: 100_000,
|
|
1991
|
+
},
|
|
1992
|
+
);
|
|
1993
|
+
// Emit visual cue on state transitions (only when adaptive mode is on)
|
|
1994
|
+
if (config.adaptive_context?.enabled && _prevAdaptiveState !== adaptiveContextState.active) {
|
|
1995
|
+
const label = adaptiveContextState.active ? "1M context ON" : "1M context OFF";
|
|
1996
|
+
const variant = adaptiveContextState.active ? "info" : "success";
|
|
1997
|
+
const est = estimatePromptTokens(requestInit.body);
|
|
1998
|
+
toast(`⬡ ${label} (est. ${Math.round(est / 1000)}K tokens)`, variant, {
|
|
1999
|
+
debounceKey: "adaptive-ctx",
|
|
2000
|
+
}).catch(() => {});
|
|
2001
|
+
}
|
|
2002
|
+
_adaptiveOverrideForRequest = config.adaptive_context?.enabled
|
|
2003
|
+
? { use1MContext: _use1MContext }
|
|
2004
|
+
: undefined;
|
|
1996
2005
|
}
|
|
1997
2006
|
|
|
1998
|
-
const _adaptiveOverride =
|
|
1999
|
-
? { use1MContext: _use1MContext }
|
|
2000
|
-
: undefined;
|
|
2007
|
+
const _adaptiveOverride = _adaptiveOverrideForRequest;
|
|
2001
2008
|
|
|
2002
2009
|
const computedBetaHeader = buildAnthropicBetaHeader(
|
|
2003
2010
|
"",
|
|
@@ -2207,26 +2214,34 @@ export async function AnthropicAuthPlugin({ client, project, directory, worktree
|
|
|
2207
2214
|
try {
|
|
2208
2215
|
const parsedBody = JSON.parse(body);
|
|
2209
2216
|
if (Array.isArray(parsedBody.messages) && parsedBody.messages.length > 4) {
|
|
2210
|
-
// Keep first 2 messages (initial context) and last
|
|
2217
|
+
// Keep first 2 messages (initial context) and last 2 messages (recent work).
|
|
2211
2218
|
// Ensure the trimmed array never ends with an assistant message (prefill),
|
|
2212
2219
|
// which would cause "does not support assistant message prefill" errors.
|
|
2213
|
-
let tailCount = 2;
|
|
2214
2220
|
const msgs = parsedBody.messages;
|
|
2215
|
-
|
|
2216
|
-
|
|
2217
|
-
|
|
2218
|
-
|
|
2219
|
-
msgs.length - tailCount > 0
|
|
2220
|
-
) {
|
|
2221
|
-
tailCount++;
|
|
2222
|
-
}
|
|
2223
|
-
const tail = msgs.slice(-tailCount);
|
|
2224
|
-
// If tail still starts/ends badly, force a user wrapper
|
|
2221
|
+
const tail = msgs.slice(-2);
|
|
2222
|
+
// If tail ends with assistant, append a user message to fix the prefill issue.
|
|
2223
|
+
// Check if the assistant's last content block is tool_use — if so, synthesize
|
|
2224
|
+
// a tool_result instead of bare "Continue." to respect the tool protocol.
|
|
2225
2225
|
if (tail.length > 0 && tail[tail.length - 1]?.role === "assistant") {
|
|
2226
|
-
tail.
|
|
2227
|
-
|
|
2228
|
-
|
|
2229
|
-
|
|
2226
|
+
const lastAssistant = tail[tail.length - 1];
|
|
2227
|
+
const lastContent = Array.isArray(lastAssistant.content) ? lastAssistant.content : [];
|
|
2228
|
+
const toolUseBlocks = lastContent.filter((b) => b.type === "tool_use");
|
|
2229
|
+
if (toolUseBlocks.length > 0) {
|
|
2230
|
+
// Synthesize tool_result for each pending tool_use
|
|
2231
|
+
tail.push({
|
|
2232
|
+
role: "user",
|
|
2233
|
+
content: toolUseBlocks.map((tu) => ({
|
|
2234
|
+
type: "tool_result",
|
|
2235
|
+
tool_use_id: tu.id,
|
|
2236
|
+
content: "[Context trimmed — previous result unavailable]",
|
|
2237
|
+
})),
|
|
2238
|
+
});
|
|
2239
|
+
} else {
|
|
2240
|
+
tail.push({
|
|
2241
|
+
role: "user",
|
|
2242
|
+
content: [{ type: "text", text: "Continue." }],
|
|
2243
|
+
});
|
|
2244
|
+
}
|
|
2230
2245
|
}
|
|
2231
2246
|
const trimmed = [
|
|
2232
2247
|
...msgs.slice(0, 2),
|
|
@@ -2635,6 +2650,12 @@ export async function AnthropicAuthPlugin({ client, project, directory, worktree
|
|
|
2635
2650
|
],
|
|
2636
2651
|
},
|
|
2637
2652
|
"experimental.session.compacting": async (input, output) => {
|
|
2653
|
+
// Reset adaptive context state on session compaction (new conversation boundary).
|
|
2654
|
+
// This prevents sticky escalation from leaking across conversations.
|
|
2655
|
+
adaptiveContextState.active = false;
|
|
2656
|
+
adaptiveContextState.lastTransitionTurn = sessionMetrics.turns;
|
|
2657
|
+
adaptiveContextState.escalatedByError = false;
|
|
2658
|
+
|
|
2638
2659
|
// Inject Anthropic-specific context into compaction
|
|
2639
2660
|
if (!accountManager) return;
|
|
2640
2661
|
const account = accountManager.getCurrentAccount();
|
|
@@ -2706,9 +2727,50 @@ const adaptiveContextState = {
|
|
|
2706
2727
|
*/
|
|
2707
2728
|
function estimatePromptTokens(bodyString) {
|
|
2708
2729
|
if (!bodyString || typeof bodyString !== "string") return 0;
|
|
2709
|
-
|
|
2710
|
-
|
|
2711
|
-
|
|
2730
|
+
try {
|
|
2731
|
+
const parsed = JSON.parse(bodyString);
|
|
2732
|
+
let charCount = 0;
|
|
2733
|
+
|
|
2734
|
+
// Count system prompt text
|
|
2735
|
+
if (Array.isArray(parsed.system)) {
|
|
2736
|
+
for (const block of parsed.system) {
|
|
2737
|
+
if (block.type === "text" && typeof block.text === "string") {
|
|
2738
|
+
charCount += block.text.length;
|
|
2739
|
+
}
|
|
2740
|
+
}
|
|
2741
|
+
} else if (typeof parsed.system === "string") {
|
|
2742
|
+
charCount += parsed.system.length;
|
|
2743
|
+
}
|
|
2744
|
+
|
|
2745
|
+
// Count messages content (text blocks, tool results) — skip tool definitions
|
|
2746
|
+
if (Array.isArray(parsed.messages)) {
|
|
2747
|
+
for (const msg of parsed.messages) {
|
|
2748
|
+
if (typeof msg.content === "string") {
|
|
2749
|
+
charCount += msg.content.length;
|
|
2750
|
+
} else if (Array.isArray(msg.content)) {
|
|
2751
|
+
for (const block of msg.content) {
|
|
2752
|
+
if (block.type === "text" && typeof block.text === "string") {
|
|
2753
|
+
charCount += block.text.length;
|
|
2754
|
+
} else if (block.type === "tool_result" && typeof block.content === "string") {
|
|
2755
|
+
charCount += block.content.length;
|
|
2756
|
+
} else if (block.type === "tool_use") {
|
|
2757
|
+
// Count serialized input as tokens
|
|
2758
|
+
charCount += JSON.stringify(block.input || {}).length;
|
|
2759
|
+
} else if (block.type === "image" || block.type === "image_url") {
|
|
2760
|
+
// Images: ~2000 tokens per image (Anthropic tile-based counting)
|
|
2761
|
+
charCount += 8000; // 2000 tokens * 4 chars/token
|
|
2762
|
+
}
|
|
2763
|
+
}
|
|
2764
|
+
}
|
|
2765
|
+
}
|
|
2766
|
+
}
|
|
2767
|
+
|
|
2768
|
+
// 4 chars/token heuristic for text content (reasonable for English + code + JSON)
|
|
2769
|
+
return Math.ceil(charCount / 4);
|
|
2770
|
+
} catch {
|
|
2771
|
+
// Fallback: raw body length / 4 if JSON parsing fails
|
|
2772
|
+
return Math.ceil(bodyString.length / 4);
|
|
2773
|
+
}
|
|
2712
2774
|
}
|
|
2713
2775
|
|
|
2714
2776
|
/**
|
|
@@ -2735,6 +2797,11 @@ function resolveAdaptiveContext(bodyString, model, adaptiveConfig) {
|
|
|
2735
2797
|
return hasOneMillionContext(model);
|
|
2736
2798
|
}
|
|
2737
2799
|
|
|
2800
|
+
// If experimental betas are disabled, context-1m will be stripped anyway — skip adaptive logic
|
|
2801
|
+
if (isTruthyEnv(process.env.CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS)) {
|
|
2802
|
+
return false;
|
|
2803
|
+
}
|
|
2804
|
+
|
|
2738
2805
|
// Model must be eligible for 1M context at all
|
|
2739
2806
|
if (!hasOneMillionContext(model)) {
|
|
2740
2807
|
return false;
|
|
@@ -2762,6 +2829,10 @@ function resolveAdaptiveContext(bodyString, model, adaptiveConfig) {
|
|
|
2762
2829
|
return true;
|
|
2763
2830
|
} else {
|
|
2764
2831
|
// Currently inactive — consider escalation
|
|
2832
|
+
// Symmetric hysteresis: require at least 2 turns before re-escalation too
|
|
2833
|
+
if (turnsSinceTransition < 2 && adaptiveContextState.lastTransitionTurn > 0) {
|
|
2834
|
+
return false;
|
|
2835
|
+
}
|
|
2765
2836
|
if (estimatedTokens > adaptiveConfig.escalation_threshold) {
|
|
2766
2837
|
// Escalate
|
|
2767
2838
|
adaptiveContextState.active = true;
|
|
@@ -4293,10 +4364,26 @@ function transformRequestBody(body, signature, runtime, betaHeader) {
|
|
|
4293
4364
|
if (Array.isArray(parsed.messages) && parsed.messages.length > 0) {
|
|
4294
4365
|
const lastMsg = parsed.messages[parsed.messages.length - 1];
|
|
4295
4366
|
if (lastMsg && lastMsg.role === "assistant") {
|
|
4296
|
-
|
|
4297
|
-
|
|
4298
|
-
|
|
4299
|
-
|
|
4367
|
+
// Check if the assistant message contains tool_use blocks — if so,
|
|
4368
|
+
// synthesize tool_result responses instead of bare "Continue." to
|
|
4369
|
+
// respect the Anthropic tool protocol.
|
|
4370
|
+
const lastContent = Array.isArray(lastMsg.content) ? lastMsg.content : [];
|
|
4371
|
+
const toolUseBlocks = lastContent.filter((b) => b.type === "tool_use");
|
|
4372
|
+
if (toolUseBlocks.length > 0) {
|
|
4373
|
+
parsed.messages.push({
|
|
4374
|
+
role: "user",
|
|
4375
|
+
content: toolUseBlocks.map((tu) => ({
|
|
4376
|
+
type: "tool_result",
|
|
4377
|
+
tool_use_id: tu.id,
|
|
4378
|
+
content: "[Result unavailable — conversation was restructured]",
|
|
4379
|
+
})),
|
|
4380
|
+
});
|
|
4381
|
+
} else {
|
|
4382
|
+
parsed.messages.push({
|
|
4383
|
+
role: "user",
|
|
4384
|
+
content: [{ type: "text", text: "Continue." }],
|
|
4385
|
+
});
|
|
4386
|
+
}
|
|
4300
4387
|
}
|
|
4301
4388
|
}
|
|
4302
4389
|
|
|
@@ -4751,9 +4838,12 @@ async function refreshAccountToken(account, client, source = "foreground", { onT
|
|
|
4751
4838
|
const { readCCCredentials } = await import("./lib/cc-credentials.mjs");
|
|
4752
4839
|
const ccCreds = readCCCredentials();
|
|
4753
4840
|
const match = ccCreds.find((c) => c.refreshToken === account.refreshToken);
|
|
4754
|
-
|
|
4841
|
+
// Accept CC credential if:
|
|
4842
|
+
// - expiresAt is in the future (normal case), OR
|
|
4843
|
+
// - expiresAt is 0/missing (CC didn't provide expiry — trust the token, let API 401 if stale)
|
|
4844
|
+
if (match && (match.expiresAt === 0 || match.expiresAt > Date.now())) {
|
|
4755
4845
|
account.access = match.accessToken;
|
|
4756
|
-
account.expires = match.expiresAt;
|
|
4846
|
+
account.expires = match.expiresAt || Date.now() + 3600_000; // default 1h if unknown
|
|
4757
4847
|
markTokenStateUpdated(account);
|
|
4758
4848
|
if (onTokensUpdated) {
|
|
4759
4849
|
try {
|
package/lib/accounts.mjs
CHANGED
|
@@ -807,6 +807,7 @@ export class AccountManager {
|
|
|
807
807
|
lastFailureTime: acc.lastFailureTime,
|
|
808
808
|
lastSwitchReason: acc.lastSwitchReason || existing?.lastSwitchReason || "initial",
|
|
809
809
|
stats: acc.stats ?? existing?.stats ?? createDefaultStats(),
|
|
810
|
+
source: acc.source || existing?.source || "oauth",
|
|
810
811
|
};
|
|
811
812
|
});
|
|
812
813
|
|
package/lib/cc-credentials.mjs
CHANGED
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
* @module cc-credentials
|
|
17
17
|
*/
|
|
18
18
|
|
|
19
|
-
import { execSync } from "node:child_process";
|
|
19
|
+
import { execSync, execFileSync } from "node:child_process";
|
|
20
20
|
import { readFileSync, existsSync } from "node:fs";
|
|
21
21
|
import { join } from "node:path";
|
|
22
22
|
import { homedir } from "node:os";
|
|
@@ -77,7 +77,7 @@ function listKeychainServices() {
|
|
|
77
77
|
*/
|
|
78
78
|
function readKeychainService(service) {
|
|
79
79
|
try {
|
|
80
|
-
const raw =
|
|
80
|
+
const raw = execFileSync("security", ["find-generic-password", "-s", service, "-w"], {
|
|
81
81
|
timeout: 5_000,
|
|
82
82
|
encoding: "utf-8",
|
|
83
83
|
stdio: ["ignore", "pipe", "ignore"],
|
|
@@ -12,6 +12,7 @@ import {
|
|
|
12
12
|
|
|
13
13
|
vi.mock("node:child_process", () => ({
|
|
14
14
|
execSync: vi.fn(),
|
|
15
|
+
execFileSync: vi.fn(),
|
|
15
16
|
}));
|
|
16
17
|
|
|
17
18
|
vi.mock("node:fs", () => ({
|
|
@@ -23,7 +24,7 @@ vi.mock("node:os", () => ({
|
|
|
23
24
|
homedir: vi.fn(() => "/home/testuser"),
|
|
24
25
|
}));
|
|
25
26
|
|
|
26
|
-
import { execSync } from "node:child_process";
|
|
27
|
+
import { execSync, execFileSync } from "node:child_process";
|
|
27
28
|
import { readFileSync, existsSync } from "node:fs";
|
|
28
29
|
import { homedir } from "node:os";
|
|
29
30
|
|
|
@@ -149,10 +150,14 @@ describe("readCCCredentialsFromKeychain", () => {
|
|
|
149
150
|
it("reads single keychain service on macOS", () => {
|
|
150
151
|
Object.defineProperty(process, "platform", { value: "darwin" });
|
|
151
152
|
|
|
153
|
+
// dump-keychain uses execSync, find-generic-password uses execFileSync
|
|
152
154
|
execSync.mockImplementation((cmd) => {
|
|
153
155
|
if (cmd === "security dump-keychain") return KEYCHAIN_DUMP_SINGLE;
|
|
154
|
-
|
|
155
|
-
|
|
156
|
+
throw new Error("unexpected execSync cmd");
|
|
157
|
+
});
|
|
158
|
+
execFileSync.mockImplementation((_bin, args) => {
|
|
159
|
+
if (args && args.includes("find-generic-password")) return JSON.stringify(VALID_CRED);
|
|
160
|
+
throw new Error("unexpected execFileSync cmd");
|
|
156
161
|
});
|
|
157
162
|
|
|
158
163
|
const result = readCCCredentialsFromKeychain();
|
|
@@ -168,12 +173,15 @@ describe("readCCCredentialsFromKeychain", () => {
|
|
|
168
173
|
let callIdx = 0;
|
|
169
174
|
execSync.mockImplementation((cmd) => {
|
|
170
175
|
if (cmd === "security dump-keychain") return KEYCHAIN_DUMP_MULTI;
|
|
171
|
-
|
|
176
|
+
throw new Error("unexpected execSync cmd");
|
|
177
|
+
});
|
|
178
|
+
execFileSync.mockImplementation((_bin, args) => {
|
|
179
|
+
if (args && args.includes("find-generic-password")) {
|
|
172
180
|
callIdx++;
|
|
173
181
|
const cred = callIdx === 1 ? VALID_CRED : VALID_CRED_2;
|
|
174
182
|
return JSON.stringify(cred);
|
|
175
183
|
}
|
|
176
|
-
throw new Error("unexpected cmd");
|
|
184
|
+
throw new Error("unexpected execFileSync cmd");
|
|
177
185
|
});
|
|
178
186
|
|
|
179
187
|
const result = readCCCredentialsFromKeychain();
|
|
@@ -197,13 +205,13 @@ describe("readCCCredentialsFromKeychain", () => {
|
|
|
197
205
|
|
|
198
206
|
execSync.mockImplementation((cmd) => {
|
|
199
207
|
if (cmd === "security dump-keychain") return KEYCHAIN_DUMP_SINGLE;
|
|
200
|
-
if (cmd.includes("find-generic-password")) {
|
|
201
|
-
const err = new Error("not found");
|
|
202
|
-
err.status = 44;
|
|
203
|
-
throw err;
|
|
204
|
-
}
|
|
205
208
|
throw new Error("unexpected");
|
|
206
209
|
});
|
|
210
|
+
execFileSync.mockImplementation(() => {
|
|
211
|
+
const err = new Error("not found");
|
|
212
|
+
err.status = 44;
|
|
213
|
+
throw err;
|
|
214
|
+
});
|
|
207
215
|
|
|
208
216
|
expect(readCCCredentialsFromKeychain()).toEqual([]);
|
|
209
217
|
});
|
|
@@ -317,11 +325,14 @@ describe("readCCCredentials", () => {
|
|
|
317
325
|
it("combines keychain and file sources on macOS", () => {
|
|
318
326
|
Object.defineProperty(process, "platform", { value: "darwin" });
|
|
319
327
|
|
|
320
|
-
// Keychain
|
|
328
|
+
// Keychain: dump-keychain via execSync, find-generic-password via execFileSync
|
|
321
329
|
execSync.mockImplementation((cmd) => {
|
|
322
330
|
if (cmd === "security dump-keychain") return KEYCHAIN_DUMP_SINGLE;
|
|
323
|
-
|
|
324
|
-
|
|
331
|
+
throw new Error("unexpected execSync cmd");
|
|
332
|
+
});
|
|
333
|
+
execFileSync.mockImplementation((_bin, args) => {
|
|
334
|
+
if (args && args.includes("find-generic-password")) return JSON.stringify(VALID_CRED);
|
|
335
|
+
throw new Error("unexpected execFileSync cmd");
|
|
325
336
|
});
|
|
326
337
|
|
|
327
338
|
// File returns a different credential
|
|
@@ -343,8 +354,11 @@ describe("readCCCredentials", () => {
|
|
|
343
354
|
|
|
344
355
|
execSync.mockImplementation((cmd) => {
|
|
345
356
|
if (cmd === "security dump-keychain") return KEYCHAIN_DUMP_SINGLE;
|
|
346
|
-
|
|
347
|
-
|
|
357
|
+
throw new Error("unexpected execSync cmd");
|
|
358
|
+
});
|
|
359
|
+
execFileSync.mockImplementation((_bin, args) => {
|
|
360
|
+
if (args && args.includes("find-generic-password")) return JSON.stringify(keychainCred);
|
|
361
|
+
throw new Error("unexpected execFileSync cmd");
|
|
348
362
|
});
|
|
349
363
|
|
|
350
364
|
existsSync.mockReturnValue(true);
|
package/lib/config.mjs
CHANGED
|
@@ -150,7 +150,7 @@ export const DEFAULT_CONFIG = {
|
|
|
150
150
|
prefer_over_oauth: false,
|
|
151
151
|
},
|
|
152
152
|
adaptive_context: {
|
|
153
|
-
enabled:
|
|
153
|
+
enabled: true,
|
|
154
154
|
escalation_threshold: 150_000,
|
|
155
155
|
deescalation_threshold: 100_000,
|
|
156
156
|
},
|
|
@@ -417,6 +417,13 @@ function validateConfig(raw) {
|
|
|
417
417
|
DEFAULT_CONFIG.adaptive_context.deescalation_threshold,
|
|
418
418
|
),
|
|
419
419
|
};
|
|
420
|
+
// Cross-validate: deescalation must be strictly less than escalation to maintain hysteresis gap
|
|
421
|
+
if (config.adaptive_context.deescalation_threshold >= config.adaptive_context.escalation_threshold) {
|
|
422
|
+
config.adaptive_context.deescalation_threshold = Math.max(
|
|
423
|
+
20_000,
|
|
424
|
+
config.adaptive_context.escalation_threshold - 50_000,
|
|
425
|
+
);
|
|
426
|
+
}
|
|
420
427
|
}
|
|
421
428
|
|
|
422
429
|
return config;
|
package/lib/config.test.mjs
CHANGED
|
@@ -308,11 +308,11 @@ describe("loadConfig", () => {
|
|
|
308
308
|
|
|
309
309
|
// --- adaptive_context config tests ---
|
|
310
310
|
|
|
311
|
-
it("has adaptive_context defaults (
|
|
311
|
+
it("has adaptive_context defaults (enabled)", () => {
|
|
312
312
|
existsSync.mockReturnValue(false);
|
|
313
313
|
const config = loadConfig();
|
|
314
314
|
expect(config.adaptive_context).toEqual({
|
|
315
|
-
enabled:
|
|
315
|
+
enabled: true,
|
|
316
316
|
escalation_threshold: 150_000,
|
|
317
317
|
deescalation_threshold: 100_000,
|
|
318
318
|
});
|
|
@@ -349,7 +349,7 @@ describe("loadConfig", () => {
|
|
|
349
349
|
existsSync.mockReturnValue(true);
|
|
350
350
|
readFileSync.mockReturnValue(JSON.stringify({ adaptive_context: { enabled: "yes" } }));
|
|
351
351
|
const config = loadConfig();
|
|
352
|
-
expect(config.adaptive_context.enabled).toBe(
|
|
352
|
+
expect(config.adaptive_context.enabled).toBe(true);
|
|
353
353
|
});
|
|
354
354
|
|
|
355
355
|
it("ignores non-number adaptive_context thresholds", () => {
|
package/lib/storage.mjs
CHANGED
|
@@ -183,6 +183,10 @@ function validateAccount(raw, now) {
|
|
|
183
183
|
typeof acc.consecutiveFailures === "number" ? Math.max(0, Math.floor(acc.consecutiveFailures)) : 0,
|
|
184
184
|
lastFailureTime: typeof acc.lastFailureTime === "number" ? acc.lastFailureTime : null,
|
|
185
185
|
lastSwitchReason: typeof acc.lastSwitchReason === "string" ? acc.lastSwitchReason : undefined,
|
|
186
|
+
source:
|
|
187
|
+
typeof acc.source === "string" && ["oauth", "cc-keychain", "cc-file"].includes(acc.source)
|
|
188
|
+
? acc.source
|
|
189
|
+
: undefined,
|
|
186
190
|
stats: validateStats(acc.stats, now),
|
|
187
191
|
};
|
|
188
192
|
}
|