Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions packages/ai/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,9 @@

### Fixed

- Login no longer double-appends a provider-owned OAuth account pool as a fake `login-N` slot copied from the flat compatibility sentinel.
- Claude Agent SDK `Lock file is already being held` is classified as a transient retryable error instead of an unknown/terminal failure.

### Removed

## [2026.9.2] - 2026-09-02
Expand Down
3 changes: 3 additions & 0 deletions packages/ai/src/auth/pool/slots.ts
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,9 @@ function nextLoginSlotName(credential: PooledCredential): string {
* user's stored bytes change until a second credential actually exists.
*/
export function appendLoginSlot(current: PooledCredential | undefined, flat: Credential): Credential {
if ("accounts" in flat && Array.isArray(flat.accounts) && flat.accounts.length > 0) {
return flat;
}
if (!current || !Array.isArray(current.accounts) || current.accounts.length === 0) {
return flat;
}
Expand Down
38 changes: 38 additions & 0 deletions packages/ai/src/changes.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,41 @@
## Classify Claude SDK session lock contention as retryable (2026-09-02)

### What changed

- `packages/ai/src/utils/retry.ts`: `RETRYABLE_PROVIDER_ERROR_PATTERN` matches `Lock file is already being held`.
- `packages/ai/test/retry.test.ts`: pins that wording as a retryable assistant error.

### Why

- Claude Agent SDK session resume/stream hits proper-lockfile while a previous subprocess still holds `session.json`. The failure is local and transient; treating it as unknown/terminal made the coding-agent hard-error fallback hop providers.

### Why an extension could not handle it

- Retry classification lives in the shared `pi-ai` regexes used by every caller of `isRetryableAssistantError`.

### Expected merge conflict zones

- LOW: `RETRYABLE_PROVIDER_ERROR_PATTERN` in `retry.ts`.

## Preserve provider-owned credential pools during login (2026-09-02)

### What changed

- `packages/ai/src/auth/pool/slots.ts`: `appendLoginSlot` now accepts an OAuth credential whose provider already returned a populated `accounts` pool as the complete post-login credential instead of appending its flat compatibility fields as another generated slot.
- `packages/ai/test/credential-pool-resolve-slot.test.ts`: covers a provider-owned named account pool and still appends unnamed flat credentials as `login-N`.

### Why

- The shared login layer automatically appends ordinary flat credentials. The Claude SDK OAuth provider already returns its current pool plus the newly named account, so applying the generic append a second time stored the provider's managed top-level sentinel as a fake `login-2` account.

### Why an extension could not handle it

- The double append happens after the provider login returns, inside the shared credential-pool write path. Providers cannot prevent the runtime from reinterpreting their completed pool as a flat credential.

### Expected merge conflict zones

- LOW: `auth/pool/slots.ts` at the start of `appendLoginSlot`.

## Cursor conversation cache eviction cannot break a live request (2026-08-31)

### What changed
Expand Down
5 changes: 5 additions & 0 deletions packages/ai/src/utils/retry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,11 @@ const RETRYABLE_PROVIDER_ERROR_PATTERN = buildProviderErrorPattern([

// gRPC based providers (e.g. NVIDIA NIM)
"ResourceExhausted",

// Claude Agent SDK session.json lock contention. A second stream/resume
// hits proper-lockfile while the previous subprocess still holds the file.
// Same-process retry recovers; hopping providers cannot release that lock.
"Lock file is already being held",
]);

/**
Expand Down
31 changes: 30 additions & 1 deletion packages/ai/test/credential-pool-resolve-slot.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { describe, expect, test } from "vitest";
import { InMemoryCredentialStore } from "../src/auth/credential-store.ts";
import { envApiKeyAuth } from "../src/auth/helpers.ts";
import { listSlots, type PooledCredential } from "../src/auth/pool/slots.ts";
import { appendLoginSlot, listSlots, type PooledCredential } from "../src/auth/pool/slots.ts";
import { resolveProviderAuth } from "../src/auth/resolve.ts";
import type { OAuthCredential } from "../src/auth/types.ts";
import { createProvider, type Provider } from "../src/models.ts";
Expand Down Expand Up @@ -63,6 +63,35 @@ function oauthProvider(refreshed: (credential: OAuthCredential) => OAuthCredenti
}

describe("slot-scoped auth resolution", () => {
test("login preserves a provider-owned pooled credential instead of double-appending its flat sentinel", () => {
const current = pooledOAuthEntry();
const providerOwned: PooledCredential = {
...current,
accounts: [
...(current.accounts ?? []),
{ name: "work", access: "named-access", refresh: "r-named", expires: FUTURE, source: "login" },
],
};

expect(appendLoginSlot(current, providerOwned)).toEqual(providerOwned);
});

test("unnamed flat oauth still appends as login-N", () => {
const current = pooledOAuthEntry();
const next = appendLoginSlot(current, {
type: "oauth",
access: "new-access",
refresh: "r-new",
expires: FUTURE,
}) as PooledCredential;

expect(listSlots(next).map((slot) => slot.name)).toEqual(["default", "alt", "login-2"]);
expect(listSlots(next).find((slot) => slot.name === "login-2")).toMatchObject({
access: "new-access",
refresh: "r-new",
});
});

test("slotName resolves the named api_key slot instead of the flat projection", async () => {
const store = new InMemoryCredentialStore();
await store.modify("slottest", async () => pooledApiKeyEntry());
Expand Down
11 changes: 11 additions & 0 deletions packages/ai/test/retry.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -242,6 +242,17 @@ describe("provider retry classification", () => {
).toBe(true);
});

it("matches Claude Agent SDK session lock contention", () => {
expect(
isRetryableAssistantError(
fauxAssistantMessage("", {
stopReason: "error",
errorMessage: "Lock file is already being held",
}),
),
).toBe(true);
});

it("matches upstream request buffer exhaustion wording", () => {
expect(
isRetryableAssistantError(
Expand Down
7 changes: 7 additions & 0 deletions packages/coding-agent/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,13 @@

### Fixed

- Claude SDK OAuth readiness now treats a rotation-selected concrete OAuth slot as configured, so a second login no longer fails every request with `Provider is not configured: claude-sdk-oauth`.
- `Provider is not configured:` is no longer a hard-error model fallback, so an auth miss on Claude SDK OAuth does not eject the turn onto another provider.
- OAuth login no longer paints two live `>` prompts when the browser callback finishes before the paste-code field is submitted.
- Claude Agent SDK `Lock file is already being held` retries on the same model and no longer hard-error-falls back onto another provider.
- Claude SDK stream-start timeouts and a bare `invalid_request` remint the same model instead of hopping to an unauthenticated OpenGateway Anthropic route.
- A persisted Claude SDK binding whose prompt/toolset drifted after a timeout now forks at the last assistant UUID instead of flattening megabytes of transcript.

### New Features

### Breaking Changes
Expand Down
23 changes: 22 additions & 1 deletion packages/coding-agent/src/core/agent-session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2354,10 +2354,11 @@ export class AgentSession {
const hardErrorFallbackEligible = this._isHardErrorFallbackEligible(msg);
const cursorZeroTokenRe = isCursorZeroTokenResourceExhausted(msg);
const cursorQuotaRe = isCursorQuotaResourceExhausted(msg, this.model?.contextWindow ?? 0);
const claudeSdkSameModelRemint = this._isClaudeSdkSameModelRemintError(msg);
const retryCanAdmitProvider =
!userAbortSuppressedQueuedContinuation &&
this.settingsManager.getRetrySettings().enabled &&
(retryableError || hardErrorFallbackEligible || cursorZeroTokenRe || cursorQuotaRe);
(retryableError || hardErrorFallbackEligible || cursorZeroTokenRe || cursorQuotaRe || claudeSdkSameModelRemint);
let compactedBeforeRetry = false;
if (
retryCanAdmitProvider &&
Expand All @@ -2381,6 +2382,8 @@ export class AgentSession {
// failed assistant before provider fallback so replay stays valid.
this._retireFailedRetryAssistant(msg);
retryOutcome = await this._handleRetryableError(msg, { hardErrorFallback: true });
} else if (claudeSdkSameModelRemint) {
retryOutcome = await this._handleRetryableError(msg, { sameModelRemint: true });
} else if (retryableError) {
retryOutcome = await this._handleRetryableError(msg);
} else if (hardErrorFallbackEligible) {
Expand Down Expand Up @@ -7375,9 +7378,27 @@ export class AgentSession {
return true;
}

private _isClaudeSdkSessionLockError(message: AssistantMessage): boolean {
return (message.errorMessage ?? "").includes("Lock file is already being held");
}

private _isClaudeSdkInvalidRequestError(message: AssistantMessage): boolean {
return message.errorMessage === "invalid_request";
}

private _isClaudeSdkSameModelRemintError(message: AssistantMessage): boolean {
return (
this._isClaudeSdkSessionLockError(message) ||
this._isClaudeSdkInvalidRequestError(message) ||
isProviderStreamStallError(message)
);
}

private _isHardErrorFallbackEligible(message: AssistantMessage): boolean {
return (
!message.errorMessage?.startsWith(TURN_RETRY_SUPPRESSION_PREFIX) &&
!message.errorMessage?.startsWith("Provider is not configured:") &&

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3: This guard couples fallback behavior to the exact English wording of an error message that is generated in two other files (Provider is not configured: ${model.provider} at packages/ai/src/models.ts:680 and packages/coding-agent/src/core/model-runtime.ts:711), with no shared constant. If either generator is reworded or localized, the startsWith check silently stops matching and the auth-miss hard-error hop this fix is meant to prevent quietly returns. Export a shared prefix constant (e.g. in packages/ai where ModelsError is defined) and reference it in both throw sites and this guard, as is done with TURN_RETRY_SUPPRESSION_PREFIX.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/coding-agent/src/core/agent-session.ts, line 7381:

<comment>This guard couples fallback behavior to the exact English wording of an error message that is generated in two other files (`Provider is not configured: ${model.provider}` at packages/ai/src/models.ts:680 and packages/coding-agent/src/core/model-runtime.ts:711), with no shared constant. If either generator is reworded or localized, the startsWith check silently stops matching and the auth-miss hard-error hop this fix is meant to prevent quietly returns. Export a shared prefix constant (e.g. in packages/ai where ModelsError is defined) and reference it in both throw sites and this guard, as is done with TURN_RETRY_SUPPRESSION_PREFIX.</comment>

<file context>
@@ -7378,6 +7378,7 @@ export class AgentSession {
 	private _isHardErrorFallbackEligible(message: AssistantMessage): boolean {
 		return (
 			!message.errorMessage?.startsWith(TURN_RETRY_SUPPRESSION_PREFIX) &&
+			!message.errorMessage?.startsWith("Provider is not configured:") &&
 			message.stopReason === "error" &&
 			!isContextOverflow(message, this.model?.contextWindow ?? 0) &&
</file context>

!this._isClaudeSdkSameModelRemintError(message) &&
message.stopReason === "error" &&
!isContextOverflow(message, this.model?.contextWindow ?? 0) &&
!this._isCursorPayloadOverflow(message) &&
Expand Down
57 changes: 57 additions & 0 deletions packages/coding-agent/src/core/changes.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,62 @@
# changes

## 2026-09-02 - Keep Claude SDK stalls and invalid_request on the same model

### What changed

- `packages/coding-agent/src/core/agent-session.ts`: `Provider stream start timed out after Nms` and a bare `invalid_request` remint the same model. They are not hard-error provider hops.
- `packages/coding-agent/test/suite/retry-fallback-hard-error.test.ts`: both recover on faux-1 and never apply a fallback chain.

### Why

- After a 1.7MB flatten the SDK timed out, then returned `invalid_request`. Hard-error fallback switched onto `opengateway/anthropic/claude-opus-4-8` which has no key and 401-looped until the goal continuation cap fired.

### Why an extension could not handle it

- Hard-error vs same-model retry is decided in `AgentSession` before extension failover runs.

### Expected merge conflict zones

- LOW: `_isHardErrorFallbackEligible` and the `agent_end` remint branch in `agent-session.ts`.

## 2026-09-02 - Retry Claude SDK session locks on the same model

### What changed

- `packages/coding-agent/src/core/agent-session.ts`: `Lock file is already being held` is same-model remint, not hard-error provider fallback.
- `packages/coding-agent/test/suite/retry-fallback-hard-error.test.ts`: lock recovers on the original model and never hops after budget exhaustion.

### Why

- A held Claude Agent SDK session lock cannot be released by switching to OpenGateway or another provider. Immediate hard-error fallback produced 401 storms and `resume_initialization_aborted` resends.

### Why an extension could not handle it

- Hard-error vs same-model retry is decided in `AgentSession` before extension failover runs.

### Expected merge conflict zones

- LOW: `_isHardErrorFallbackEligible` and the `agent_end` retry branch in `agent-session.ts`.

## 2026-09-02 - Do not hard-fallback a provider-not-configured auth miss

### What changed

- `packages/coding-agent/src/core/agent-session.ts`: `_isHardErrorFallbackEligible` no longer treats `Provider is not configured:` as a model hard-error that ejects onto another provider.
- `packages/coding-agent/test/suite/retry-fallback-hard-error.test.ts`: a configured fallback chain stays unused when the current model fails with that auth miss.

### Why

- Auth wiring failures were classified as hard-error and immediately switched `claude-sdk-oauth/claude-opus-5` onto a different provider (for example `opengateway/anthropic/claude-opus-5`) instead of staying on Claude SDK OAuth or its sibling accounts.

### Why an extension could not handle it

- Hard-error fallback eligibility is decided in `AgentSession` before extension failover runs.

### Expected merge conflict zones

- LOW: `_isHardErrorFallbackEligible` in `agent-session.ts`.

## 2026-08-31 - Session activity contract for host occupancy decisions

### What changed
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,43 @@
# claude-sdk-oauth

## 2026-09-02 - Fork persisted bindings on options drift instead of flattening

### What changed

- `session-continuity.ts`: a persisted binding whose account/model/prompt/toolset drifted now forks at the last assistant UUID instead of flattening the whole transcript.
- `test/claude-sdk-oauth-restored-security.test.ts` and `test/claude-sdk-oauth-continuity-retry-checkpoint.test.ts`: drift after a timeout teardown is a fork, not a 1.7MB flatten.

### Why

- Stream-start timeouts tear down the live registry entry. The next attempt only had the sidecar binding, and `options_changed` (reload / cache-warm / toolset hash) flattened hundreds of messages. That megabyte resend then timed out again and hopped providers.

### Why an extension could not handle it

- Continuity decisions live inside the Claude SDK OAuth resident lane.

### Expected merge conflict zones

- LOW: `decideFromBinding` identity-drift branch in `session-continuity.ts`.

## 2026-09-02 - Preserve selected OAuth slots during provider preflight

### What changed

- `oauth-login.ts`: readiness now recognizes a concrete OAuth credential selected by the shared credential-rotation layer, while still excluding the provider's synthetic managed sentinel.
- `test/claude-sdk-oauth-login.test.ts`: projected non-sentinel slots pass `check`; projected sentinels do not.

### Why

- With two or more Claude logins, shared credential rotation passes one selected OAuth slot to provider auth resolution. The Claude readiness predicate counted only the parent credential's `accounts` array, so the selected slot appeared empty and the request failed with `Provider is not configured: claude-sdk-oauth` even though both accounts were valid.

### Why an extension could not handle it

- Readiness is this provider's `oauth.check` predicate. An external extension cannot change how a selected slot is counted once rotation has already projected it.

### Expected merge conflict zones

- LOW in `oauth-login.ts` `configuredFor` account counting.

## 2026-08-21 - Cache provider settings loads by mtime+size to cut lock convoy

### What changed
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -109,9 +109,13 @@ export function createOAuthConfig(deps: {
environment?: Record<string, string>,
): Promise<boolean> => {
const storedAccounts = stored?.type === "oauth" && Array.isArray(stored.accounts) ? stored.accounts : [];
const selectedStoredAccount =
stored?.type === "oauth" &&
stored.access !== SENTINEL_OAUTH_FIELDS.access &&
stored.refresh !== SENTINEL_OAUTH_FIELDS.refresh;
const effectiveEnvironment = environment ?? (await claudeEnvironment(ctx));
const environmentTokenCount = Object.values(effectiveEnvironment).filter(Boolean).length;
const accountCount = storedAccounts.length + environmentTokenCount;
const accountCount = storedAccounts.length + (selectedStoredAccount ? 1 : 0) + environmentTokenCount;
const settings = deps.readSettings?.();
const lane = settings?.tokenInjection ?? (accountCount > 0 ? "oauth-slots" : "ambient");
if (lane === "ambient") {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -132,10 +132,24 @@ function retryCheckpointDecision(
};
}

function forkBindingOrFlatten(
binding: ContinuityBindingSnapshot,
reason: ContinuityReason,
): ContinuityDecision {
if (!binding.lastAssistantUuid) return { kind: "flatten", reason };
return {
kind: "fork",
sdkSessionId: binding.sdkSessionId,
atUuid: binding.lastAssistantUuid,
from: binding.sentCount,
reason,
};
}

function decideFromBinding(input: ContinuityDecisionInput, binding: ContinuityBindingSnapshot): ContinuityDecision {
if (!input.transcriptAvailable) return { kind: "flatten", reason: "transcript_missing" };
const drift = identityDrift(input, binding);
if (drift) return { kind: "flatten", reason: drift };
if (drift) return forkBindingOrFlatten(binding, drift);
const retry = retryCheckpointDecision(input, binding);
if (retry) return retry;
if (binding.sentPrefixHash !== undefined) {
Expand Down
19 changes: 19 additions & 0 deletions packages/coding-agent/src/modes/interactive/changes.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,24 @@
# changes

## 2026-09-02 - Do not paint two live login inputs

### What changed

- `packages/coding-agent/src/modes/interactive/components/login-dialog.ts`: `showManualInput` and `showPrompt` remount the single Input widget instead of adding it twice, so a browser-callback login no longer shows two stacked `>` prompts.
- `packages/coding-agent/test/suite/regressions/5433-extension-oauth-prompt-input.test.ts`: covers an unsubmitted paste-code prompt followed by the account-name prompt.

### Why

- Anthropic OAuth completes via localhost callback while the paste-code input is still mounted. The name prompt then added the same Input child again, and the TUI painted two live `>` rows.

### Why an extension could not handle it

- Login chrome is the interactive LoginDialogComponent, not an extension surface.

### Expected merge conflict zones

- LOW: `showManualInput` / `showPrompt` in `login-dialog.ts`.

## 2026-09-01 - Never swallow an interactive quit request

### What changed
Expand Down
Loading