diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index 0640f03a57..b485239666 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -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 diff --git a/packages/ai/src/auth/pool/slots.ts b/packages/ai/src/auth/pool/slots.ts index a9eda87ff8..1ad2a744cd 100644 --- a/packages/ai/src/auth/pool/slots.ts +++ b/packages/ai/src/auth/pool/slots.ts @@ -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; } diff --git a/packages/ai/src/changes.md b/packages/ai/src/changes.md index b108b431b8..d0e934fd4b 100644 --- a/packages/ai/src/changes.md +++ b/packages/ai/src/changes.md @@ -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 diff --git a/packages/ai/src/utils/retry.ts b/packages/ai/src/utils/retry.ts index 96dce50753..d453bb5905 100644 --- a/packages/ai/src/utils/retry.ts +++ b/packages/ai/src/utils/retry.ts @@ -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", ]); /** diff --git a/packages/ai/test/credential-pool-resolve-slot.test.ts b/packages/ai/test/credential-pool-resolve-slot.test.ts index 934930d775..d75a101ff8 100644 --- a/packages/ai/test/credential-pool-resolve-slot.test.ts +++ b/packages/ai/test/credential-pool-resolve-slot.test.ts @@ -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"; @@ -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()); diff --git a/packages/ai/test/retry.test.ts b/packages/ai/test/retry.test.ts index 5e1fc74e02..12afa421f9 100644 --- a/packages/ai/test/retry.test.ts +++ b/packages/ai/test/retry.test.ts @@ -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( diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 667dd58d51..1446f8d61c 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -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 diff --git a/packages/coding-agent/src/core/agent-session.ts b/packages/coding-agent/src/core/agent-session.ts index df0249c23c..1846889c51 100644 --- a/packages/coding-agent/src/core/agent-session.ts +++ b/packages/coding-agent/src/core/agent-session.ts @@ -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 && @@ -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) { @@ -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:") && + !this._isClaudeSdkSameModelRemintError(message) && message.stopReason === "error" && !isContextOverflow(message, this.model?.contextWindow ?? 0) && !this._isCursorPayloadOverflow(message) && diff --git a/packages/coding-agent/src/core/changes.md b/packages/coding-agent/src/core/changes.md index e9794623f6..f235483e72 100644 --- a/packages/coding-agent/src/core/changes.md +++ b/packages/coding-agent/src/core/changes.md @@ -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 diff --git a/packages/coding-agent/src/core/extensions/builtin/claude-sdk-oauth/changes.md b/packages/coding-agent/src/core/extensions/builtin/claude-sdk-oauth/changes.md index f3ec8e3443..9848775506 100644 --- a/packages/coding-agent/src/core/extensions/builtin/claude-sdk-oauth/changes.md +++ b/packages/coding-agent/src/core/extensions/builtin/claude-sdk-oauth/changes.md @@ -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 diff --git a/packages/coding-agent/src/core/extensions/builtin/claude-sdk-oauth/oauth-login.ts b/packages/coding-agent/src/core/extensions/builtin/claude-sdk-oauth/oauth-login.ts index 1766ee58fc..93628ee0f9 100644 --- a/packages/coding-agent/src/core/extensions/builtin/claude-sdk-oauth/oauth-login.ts +++ b/packages/coding-agent/src/core/extensions/builtin/claude-sdk-oauth/oauth-login.ts @@ -109,9 +109,13 @@ export function createOAuthConfig(deps: { environment?: Record, ): Promise => { 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") { diff --git a/packages/coding-agent/src/core/extensions/builtin/claude-sdk-oauth/session-continuity.ts b/packages/coding-agent/src/core/extensions/builtin/claude-sdk-oauth/session-continuity.ts index 40b214b041..117bc3028b 100644 --- a/packages/coding-agent/src/core/extensions/builtin/claude-sdk-oauth/session-continuity.ts +++ b/packages/coding-agent/src/core/extensions/builtin/claude-sdk-oauth/session-continuity.ts @@ -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) { diff --git a/packages/coding-agent/src/modes/interactive/changes.md b/packages/coding-agent/src/modes/interactive/changes.md index 39620060ab..71bfd47fb2 100644 --- a/packages/coding-agent/src/modes/interactive/changes.md +++ b/packages/coding-agent/src/modes/interactive/changes.md @@ -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 diff --git a/packages/coding-agent/src/modes/interactive/components/login-dialog.ts b/packages/coding-agent/src/modes/interactive/components/login-dialog.ts index 88371b9d56..ba29fc19ab 100644 --- a/packages/coding-agent/src/modes/interactive/components/login-dialog.ts +++ b/packages/coding-agent/src/modes/interactive/components/login-dialog.ts @@ -15,6 +15,7 @@ export class LoginDialogComponent extends Container implements Focusable { private abortController = new AbortController(); private inputResolver?: (value: string) => void; private inputRejecter?: (error: Error) => void; + private liveHint?: Text; private onComplete: (success: boolean, message?: string) => void; // Focusable implementation - propagate to input for IME cursor positioning @@ -80,6 +81,16 @@ export class LoginDialogComponent extends Container implements Focusable { ); } + /** The Input widget is a single instance; mounting it twice paints two live `>` rows. */ + private remountInput(hint: Text): void { + this.contentContainer.children = this.contentContainer.children.filter( + (child) => child !== this.input && child !== this.liveHint, + ); + this.contentContainer.addChild(this.input); + this.liveHint = hint; + this.contentContainer.addChild(hint); + } + private cancel(): void { this.abortController.abort(); if (this.inputRejecter) { @@ -137,8 +148,7 @@ export class LoginDialogComponent extends Container implements Focusable { this.input.setValue(""); this.contentContainer.addChild(new Spacer(1)); this.contentContainer.addChild(new Text(theme.fg("dim", prompt), 1, 0)); - this.contentContainer.addChild(this.input); - this.contentContainer.addChild(new Text(`(${keyHint("tui.select.cancel", "to cancel")})`, 1, 0)); + this.remountInput(new Text(`(${keyHint("tui.select.cancel", "to cancel")})`, 1, 0)); this.tui.requestRender(); return new Promise((resolve, reject) => { @@ -157,8 +167,7 @@ export class LoginDialogComponent extends Container implements Focusable { if (placeholder) { this.contentContainer.addChild(new Text(theme.fg("dim", `e.g., ${placeholder}`), 1, 0)); } - this.contentContainer.addChild(this.input); - this.contentContainer.addChild( + this.remountInput( new Text( `(${keyHint("tui.select.cancel", "to cancel,")} ${keyHint("tui.select.confirm", "to submit")})`, 1, diff --git a/packages/coding-agent/test/claude-sdk-oauth-continuity-retry-checkpoint.test.ts b/packages/coding-agent/test/claude-sdk-oauth-continuity-retry-checkpoint.test.ts index 1fd59c156d..2c78de6b87 100644 --- a/packages/coding-agent/test/claude-sdk-oauth-continuity-retry-checkpoint.test.ts +++ b/packages/coding-agent/test/claude-sdk-oauth-continuity-retry-checkpoint.test.ts @@ -88,12 +88,12 @@ describe("claude-sdk-oauth retry checkpoint continuity", () => { }); it("does not let a checkpoint outrank an identity drift", () => { - expect(decideNativeContinuity(input({ modelId: "claude-sonnet-5" }))).toEqual({ - kind: "flatten", + expect(decideNativeContinuity(input({ modelId: "claude-sonnet-5" }))).toMatchObject({ + kind: "fork", reason: "model_changed", }); - expect(decideNativeContinuity(input({ accountName: "secondary" }))).toEqual({ - kind: "flatten", + expect(decideNativeContinuity(input({ accountName: "secondary" }))).toMatchObject({ + kind: "fork", reason: "account_changed", }); }); diff --git a/packages/coding-agent/test/claude-sdk-oauth-login.test.ts b/packages/coding-agent/test/claude-sdk-oauth-login.test.ts index 2ff4b642ce..8fff5b9f1e 100644 --- a/packages/coding-agent/test/claude-sdk-oauth-login.test.ts +++ b/packages/coding-agent/test/claude-sdk-oauth-login.test.ts @@ -101,4 +101,24 @@ describe("claude-sdk-oauth oauth login config", () => { const credential = await config.login({}); expect(await config.refreshToken(credential)).toBe(credential); }); + + it("treats a projected non-sentinel OAuth slot as configured", async () => { + const config = createOAuthConfig({ readCurrent: async () => undefined, loginFlow: fakeFlow(fresh) }); + const ctx = { env: async () => undefined, fileExists: async () => false }; + const check = await config.check({ + ctx, + credential: { type: "oauth", access: "slot-access", refresh: "slot-refresh", expires: Date.now() + 60_000 }, + }); + expect(check).toEqual({ source: "Claude SDK OAuth", type: "oauth" }); + }); + + it("does not treat a projected managed sentinel as configured", async () => { + const config = createOAuthConfig({ readCurrent: async () => undefined, loginFlow: fakeFlow(fresh) }); + const ctx = { env: async () => undefined, fileExists: async () => false }; + const check = await config.check({ + ctx, + credential: { type: "oauth", ...SENTINEL_OAUTH_FIELDS }, + }); + expect(check).toBeUndefined(); + }); }); diff --git a/packages/coding-agent/test/claude-sdk-oauth-restored-security.test.ts b/packages/coding-agent/test/claude-sdk-oauth-restored-security.test.ts index 17f4315eb2..3f32b28c76 100644 --- a/packages/coding-agent/test/claude-sdk-oauth-restored-security.test.ts +++ b/packages/coding-agent/test/claude-sdk-oauth-restored-security.test.ts @@ -99,9 +99,15 @@ describe("claude-sdk-oauth restored security", () => { "options_changed", ], ["toolset", { fingerprint: { systemPromptHash: "prompt-v1", toolsetHash: "tools-v2" } }, "options_changed"], - ] as const)("cold-seeds a persisted binding when %s drifts", (_label, override, reason) => { + ] as const)("forks a persisted binding when %s drifts instead of flattening the whole transcript", (_label, override, reason) => { const decision = decideNativeContinuity(baseInput(override)); - expect(decision).toEqual({ kind: "flatten", reason }); + expect(decision).toEqual({ + kind: "fork", + sdkSessionId: SDK_SESSION_ID, + atUuid: ASSISTANT_UUID, + from: 2, + reason, + }); }); it.each([ diff --git a/packages/coding-agent/test/suite/regressions/5433-extension-oauth-prompt-input.test.ts b/packages/coding-agent/test/suite/regressions/5433-extension-oauth-prompt-input.test.ts index 482f0440d6..b28f5f929d 100644 --- a/packages/coding-agent/test/suite/regressions/5433-extension-oauth-prompt-input.test.ts +++ b/packages/coding-agent/test/suite/regressions/5433-extension-oauth-prompt-input.test.ts @@ -96,6 +96,24 @@ describe("LoginDialogComponent OAuth prompts", () => { expect(output).toContain("Enter API key:"); }); + test("does not paint two live inputs when a later prompt starts before the first is submitted", () => { + const dialog = createDialog(); + + dialog.showAuth("https://example.invalid/login"); + void dialog.showManualInput("Complete login in your browser, or paste the authorization code / redirect URL here:"); + dialog.showProgress("Exchanging authorization code for tokens..."); + void dialog.showPrompt("Name for this account (existing: default)", "account-2"); + dialog.handleInput("jgplabs"); + + const lines = renderDialog(dialog); + const output = lines.join("\n"); + expect(output).toContain("https://example.invalid/login"); + expect(output).toContain("Exchanging authorization code for tokens..."); + expect(output).toContain("Name for this account (existing: default)"); + expect(countRenderedValue(lines, "jgplabs")).toBe(1); + expect(lines.filter((line) => /^>\s*$/.test(line.trim()) || line.trim() === ">").length).toBe(0); + }); + test("keeps previous manual input stable when a later prompt is active", async () => { const dialog = createDialog(); diff --git a/packages/coding-agent/test/suite/retry-fallback-hard-error.test.ts b/packages/coding-agent/test/suite/retry-fallback-hard-error.test.ts index b386aea7c9..5f43984202 100644 --- a/packages/coding-agent/test/suite/retry-fallback-hard-error.test.ts +++ b/packages/coding-agent/test/suite/retry-fallback-hard-error.test.ts @@ -206,6 +206,102 @@ describe("retry fallback hard errors", () => { expect(harness.eventsOfType("retry_fallback_applied")).toEqual([]); }); + it("retries a Claude SDK stream-start timeout on the same model instead of hopping providers", async () => { + const harness = await createHarness({ + models: [{ id: "faux-1" }, { id: "faux-2" }], + settings: { + retry: { enabled: true, maxRetries: 2, baseDelayMs: 1, fallbackChains: { [primary]: [fallback] } }, + }, + }); + harnesses.push(harness); + const timeout = "Provider stream start timed out after 90000ms"; + harness.setResponses([ + fauxAssistantMessage("", { stopReason: "error", errorMessage: timeout }), + fauxAssistantMessage("recovered after stall"), + ]); + + await harness.session.prompt("hello"); + + expect(harness.faux.getCallLog().map((call) => call.modelId)).toEqual(["faux-1", "faux-1"]); + expect(harness.eventsOfType("retry_fallback_applied")).toEqual([]); + }); + + it("does not hop providers on a bare Claude SDK invalid_request", async () => { + const harness = await createHarness({ + models: [{ id: "faux-1" }, { id: "faux-2" }], + settings: { + retry: { enabled: true, maxRetries: 1, baseDelayMs: 1, fallbackChains: { [primary]: [fallback] } }, + }, + }); + harnesses.push(harness); + harness.setResponses([ + fauxAssistantMessage("", { stopReason: "error", errorMessage: "invalid_request" }), + fauxAssistantMessage("recovered after invalid_request"), + ]); + + await harness.session.prompt("hello"); + + expect(harness.faux.getCallLog().map((call) => call.modelId)).toEqual(["faux-1", "faux-1"]); + expect(harness.eventsOfType("retry_fallback_applied")).toEqual([]); + }); + + it("retries a Claude SDK session lock on the same model instead of hopping providers", async () => { + const harness = await createHarness({ + models: [{ id: "faux-1" }, { id: "faux-2" }], + settings: { + retry: { enabled: true, maxRetries: 2, baseDelayMs: 1, fallbackChains: { [primary]: [fallback] } }, + }, + }); + harnesses.push(harness); + const lock = "Lock file is already being held"; + harness.setResponses([ + fauxAssistantMessage("", { stopReason: "error", errorMessage: lock }), + fauxAssistantMessage("recovered after lock"), + ]); + + await harness.session.prompt("hello"); + + expect(harness.faux.getCallLog().map((call) => call.modelId)).toEqual(["faux-1", "faux-1"]); + expect(harness.eventsOfType("retry_fallback_applied")).toEqual([]); + expect(harness.eventsOfType("auto_retry_end").map((event) => event.success)).toEqual([true]); + }); + + it("does not hop providers when a Claude SDK session lock exhausts same-model retries", async () => { + const harness = await createHarness({ + models: [{ id: "faux-1" }, { id: "faux-2" }], + settings: { + retry: { enabled: true, maxRetries: 1, baseDelayMs: 1, fallbackChains: { [primary]: [fallback] } }, + }, + }); + harnesses.push(harness); + const lock = "Lock file is already being held"; + harness.setResponses([ + fauxAssistantMessage("", { stopReason: "error", errorMessage: lock }), + fauxAssistantMessage("", { stopReason: "error", errorMessage: lock }), + ]); + + await harness.session.prompt("hello"); + + expect(harness.faux.getCallLog().map((call) => call.modelId)).toEqual(["faux-1", "faux-1"]); + expect(harness.eventsOfType("retry_fallback_applied")).toEqual([]); + }); + + it("does not switch providers on a provider-not-configured auth miss", async () => { + const harness = await createHarness({ + models: [{ id: "faux-1" }, { id: "faux-2" }], + settings: { retry: { enabled: true, baseDelayMs: 1, fallbackChains: { [primary]: [fallback] } } }, + }); + harnesses.push(harness); + const authMiss = "Provider is not configured: claude-sdk-oauth"; + harness.setResponses([fauxAssistantMessage("", { stopReason: "error", errorMessage: authMiss })]); + + await harness.session.prompt("hello"); + + expect(harness.faux.getCallLog().map((call) => call.modelId)).toEqual(["faux-1"]); + expect(harness.eventsOfType("retry_fallback_applied")).toEqual([]); + expect(harness.session.state.messages.at(-1)).toMatchObject({ errorMessage: authMiss }); + }); + it("does not treat an aborted response as a hard-error fallback", async () => { const harness = await createHarness({ models: [{ id: "faux-1" }, { id: "faux-2" }],