diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index e77786fd28..558e5a5af6 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -10,6 +10,9 @@ ### Fixed +- Provider-owned OAuth account pools are preserved during login instead of being appended again as a duplicate slot + from their flat compatibility credential. + ### Removed ## [2026.8.30-3] - 2026-08-30 diff --git a/packages/ai/src/auth/pool/slots.ts b/packages/ai/src/auth/pool/slots.ts index a9eda87ff8..f6885f7b68 100644 --- a/packages/ai/src/auth/pool/slots.ts +++ b/packages/ai/src/auth/pool/slots.ts @@ -146,6 +146,10 @@ 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 { + const providerOwned = flat as PooledCredential; + if (Array.isArray(providerOwned.accounts) && providerOwned.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 bd1e2af0a7..e174f6519d 100644 --- a/packages/ai/src/changes.md +++ b/packages/ai/src/changes.md @@ -1,3 +1,29 @@ +## Preserve provider-owned credential pools during login (2026-08-30) + +### 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 rejects the + duplicate `login-2` slot that previously copied the flat compatibility sentinel. + +### 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 account. Session affinity could select that fake account and fail + otherwise valid requests as `Provider is not configured`. + +### 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`. + ## Stop replaying the Anthropic server-side fallback marker (2026-08-30) ### What changed diff --git a/packages/ai/test/credential-pool-resolve-slot.test.ts b/packages/ai/test/credential-pool-resolve-slot.test.ts index 934930d775..fa51a994da 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,19 @@ 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: "named", access: "named-access", refresh: "r-named", expires: FUTURE, source: "login" }, + ], + }; + + expect(appendLoginSlot(current, providerOwned)).toEqual(providerOwned); + }); + 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/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 174de85696..f68a044803 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -78,6 +78,9 @@ ### Fixed +- Claude SDK OAuth no longer treats DNS, connection, timeout, socket, or fetch failures during token refresh as a + rejected token that blocks the account until re-login; real OAuth rejection signals remain persistent auth errors. + - Compiled standalone binaries can now start the shared interactive RPC host. The host launch re-enters the executable through the internal supervisor route instead of a script path, which compiled entrypoints parse as CLI arguments; previously every interactive launch stalled for the full 10s readiness budget, printed `Error: Unknown option: --socket`, and fell back to a local session. - A compaction started right after `switch_session`, `new_session`, or `fork` is no longer cancelled by the replacement's own extension binding. Binding deactivates tools for the active model, and that tool change aborted the in-flight compaction with "Compaction cancelled". diff --git a/packages/coding-agent/src/core/extensions/builtin/claude-sdk-oauth/auth-lane.ts b/packages/coding-agent/src/core/extensions/builtin/claude-sdk-oauth/auth-lane.ts index c95885ec8f..3e19ff614a 100644 --- a/packages/coding-agent/src/core/extensions/builtin/claude-sdk-oauth/auth-lane.ts +++ b/packages/coding-agent/src/core/extensions/builtin/claude-sdk-oauth/auth-lane.ts @@ -143,7 +143,10 @@ async function prepareSlot( Object.assign(slot, updated); } catch (error) { const detail = error instanceof Error ? error.message : String(error); - throw new Error(`authentication_failed: ${detail}`); + const classification = classifySdkError(error); + const code = + classification.kind === "other" && classification.retryable ? "server_error" : "authentication_failed"; + throw new Error(`${code}: ${detail}`); } } const access = slot.source === "env" ? envSlotToken((name) => environment[name], slot.name) : slot.access; 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..74d6695417 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,62 @@ # claude-sdk-oauth +## 2026-08-30 - Keep transient OAuth refresh failures temporary + +### What changed + +- `errors.ts`: DNS, connection, timeout, socket, and fetch transport failures classify as retryable transient errors + before the generic `authentication_failed` SDK code is considered. Explicit `invalid_grant`, `invalid_token`, + revoked-token, HTTP 401, and unauthorized signals remain persistent `auth_error` classifications. +- `auth-lane.ts`: recognized transient refresh failures surface as `server_error` instead of claiming that the OAuth + token was rejected. +- `guidance.ts`: all-account guidance now names transient provider failures among temporary block causes. +- `test/claude-sdk-oauth-failover.test.ts`: locks the transient/auth boundary with the observed + `ENOTFOUND platform.claude.com`, Undici timeout, connection-reset, invalid-grant, and HTTP 401 shapes. + +### Why + +- A temporary DNS outage made the Anthropic refresh request fail before the server could inspect the token. The auth + lane wrapped every refresh exception as `authentication_failed`; failover therefore persisted `auth_error`, which + never expires and incorrectly required re-login. Other external APIs failed DNS in the same window, confirming a + transport outage rather than token rejection. + +### Why an extension could not handle it + +- Refresh executes inside this builtin's managed account lane before the Claude subprocess starts. Only this layer + can preserve the distinction between transport failure and an OAuth server rejection before failover persists the + account state. + +### Expected merge conflict zones + +- LOW in `errors.ts` before SDK-code matching, `auth-lane.ts` refresh error wrapping, and `guidance.ts` blocked text. + +## 2026-08-30 - 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/suite/claude-sdk-oauth-extension.test.ts`: the stream preflight coverage now uses two stored logins so it + exercises the selected-slot request path. + +### 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 + +- The predicate is part of the builtin provider's private OAuth configuration and runs after the shared runtime has + selected a credential slot. An external extension cannot repair that credential interpretation without replacing + the provider registration. + +### Expected merge conflict zones + +- LOW in `oauth-login.ts` around `configuredFor` account counting. +- LOW in `test/suite/claude-sdk-oauth-extension.test.ts` around the stored-login stream preflight. + ## 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/errors.ts b/packages/coding-agent/src/core/extensions/builtin/claude-sdk-oauth/errors.ts index 0cad7c0d6f..37c8d4fd36 100644 --- a/packages/coding-agent/src/core/extensions/builtin/claude-sdk-oauth/errors.ts +++ b/packages/coding-agent/src/core/extensions/builtin/claude-sdk-oauth/errors.ts @@ -18,6 +18,8 @@ const SDK_ERROR_CLASSIFICATIONS: Partial | undefined { return value !== null && typeof value === "object" && !Array.isArray(value) @@ -40,9 +42,24 @@ function errorText(error: unknown): string { /** Classifies Claude SDK OAuth error codes and HTTP-shaped fallback text in one place. */ export function classifySdkError(error: unknown): SdkErrorClassification { const text = errorText(error).toLowerCase(); + // Transport failures during token refresh do not say anything about token + // validity. Match them before `authentication_failed` because the auth lane + // wraps every refresh exception with that SDK-compatible prefix. + if ( + /\b(?:enotfound|eai_again|econnreset|econnrefused|etimedout|enetworkdown|enetunreach|ehostunreach|und_err_connect_timeout|und_err_socket)\b|fetch failed|network(?: request)? (?:failed|error)|socket hang up|connection reset by peer/.test( + text, + ) + ) { + return TRANSIENT_NETWORK_ERROR; + } for (const [code, classification] of Object.entries(SDK_ERROR_CLASSIFICATIONS)) { if (new RegExp(`\\b${code}\\b`).test(text)) return classification; } + if ( + /\binvalid_grant\b|\binvalid_token\b|\btoken\b[^.]*\brevoked\b|\b(?:http\s*)?401\b|\bunauthorized\b/.test(text) + ) { + return AUTH_ERROR; + } if (/\b(?:http\s*)?429\b|too many requests|rate[ _-]?limit/.test(text)) { return { kind: "rate_limit", retryable: true }; } diff --git a/packages/coding-agent/src/core/extensions/builtin/claude-sdk-oauth/guidance.ts b/packages/coding-agent/src/core/extensions/builtin/claude-sdk-oauth/guidance.ts index 5407361237..fcf8fa57bc 100644 --- a/packages/coding-agent/src/core/extensions/builtin/claude-sdk-oauth/guidance.ts +++ b/packages/coding-agent/src/core/extensions/builtin/claude-sdk-oauth/guidance.ts @@ -23,7 +23,7 @@ export function allAccountsBlockedGuidance(soonestUnblockAt: number | undefined) ? new Date(soonestUnblockAt).toISOString() : "after re-login"; return [ - `All Claude accounts for ${PROVIDER} are currently blocked (rate limit or auth errors).`, + `All Claude accounts for ${PROVIDER} are currently blocked (rate limit, transient provider, or auth errors).`, ` Soonest automatic retry: ${eta}.`, ` /claude-account list - inspect account states`, ` /login ${PROVIDER} - add another account`, 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/modes/interactive/interactive-host-runtime.ts b/packages/coding-agent/src/modes/interactive/interactive-host-runtime.ts index e7ef3196d8..5829d17189 100644 --- a/packages/coding-agent/src/modes/interactive/interactive-host-runtime.ts +++ b/packages/coding-agent/src/modes/interactive/interactive-host-runtime.ts @@ -155,7 +155,7 @@ export async function createInteractiveHostRuntime( client, opened.state, options.onWarning, -(cause) => { + (cause) => { if (remoteRuntime?.isReconnecting) warnReconnect(cause); else warnFallback(cause); }, @@ -460,7 +460,7 @@ export function createRemoteSessionProxy( client: RpcClient, initialState: ReturnType, onWarning?: (warning: InteractiveHostWarning) => void, -onTransportGone?: (cause: unknown) => void, + onTransportGone?: (cause: unknown) => void, startupEvents: readonly import("../rpc/rpc-client.ts").RpcClientEvent[] = [], ): RemoteSessionProxy { // Fire-and-forget setters keep the sync AgentSession signature, but their RPC diff --git a/packages/coding-agent/src/modes/rpc/host-ensure.ts b/packages/coding-agent/src/modes/rpc/host-ensure.ts index 45b0cda42c..b562531421 100644 --- a/packages/coding-agent/src/modes/rpc/host-ensure.ts +++ b/packages/coding-agent/src/modes/rpc/host-ensure.ts @@ -71,10 +71,7 @@ const REQUIRED_CAPABILITIES = ["multi_session", EXTENSION_EVENTS_CAPABILITY] as * the first caller. In particular, extension_events must remain available when * a terminal client starts the shared host before the desktop connects. */ -export const PINNED_HOST_CLIENT_CAPABILITIES = [ - EXTENSION_EVENTS_CAPABILITY, - CUSTOM_UNSUPPORTED_CAPABILITY, -] as const; +export const PINNED_HOST_CLIENT_CAPABILITIES = [EXTENSION_EVENTS_CAPABILITY, CUSTOM_UNSUPPORTED_CAPABILITY] as const; export function createHostDaemonPaths(agentDir = getAgentDir()): HostDaemonPaths { const dir = join(agentDir, "rpc-host-daemon"); diff --git a/packages/coding-agent/src/modes/rpc/session-event-writer.ts b/packages/coding-agent/src/modes/rpc/session-event-writer.ts index f51835003c..697b972933 100644 --- a/packages/coding-agent/src/modes/rpc/session-event-writer.ts +++ b/packages/coding-agent/src/modes/rpc/session-event-writer.ts @@ -159,7 +159,8 @@ export class SessionEventWriter { sessions.add(sessionId); this.connectionSessions.set(id, sessions); if (this.connectionCapabilities.get(id)?.has("rendered_components")) - for (const record of this.sessionSnapshots.get(sessionId) ?? []) if (record.rendered) this.connections.get(id)!.actor.enqueue(record.line); + for (const record of this.sessionSnapshots.get(sessionId) ?? []) + if (record.rendered) this.connections.get(id)!.actor.enqueue(record.line); } detachConnectionFromSession(id: string, sessionId: string): void { @@ -174,7 +175,8 @@ export class SessionEventWriter { this.registeredCapabilityConnections.add(id); if (!wasCapable && capabilities.includes("rendered_components")) for (const sessionId of this.connectionSessions.get(id) ?? []) - for (const record of this.sessionSnapshots.get(sessionId) ?? []) if (record.rendered) registered.actor.enqueue(record.line); + for (const record of this.sessionSnapshots.get(sessionId) ?? []) + if (record.rendered) registered.actor.enqueue(record.line); } clearConnectionCapabilities(id: string): void { @@ -226,9 +228,10 @@ export class SessionEventWriter { const targets = isTargeted ? [targetId] : record[RENDERED_COMPONENT_RECORD] && this.connections.size > 0 - ? [...this.connections.keys()].filter((id) => - this.connectionCapabilities.get(id)?.has("rendered_components") && - this.connectionSessions.get(id)?.has(sessionId), + ? [...this.connections.keys()].filter( + (id) => + this.connectionCapabilities.get(id)?.has("rendered_components") && + this.connectionSessions.get(id)?.has(sessionId), ) : this.connections.size > 0 ? [...this.connections.keys()] diff --git a/packages/coding-agent/test/claude-sdk-oauth-failover.test.ts b/packages/coding-agent/test/claude-sdk-oauth-failover.test.ts index e25ac2fc55..11ada052f0 100644 --- a/packages/coding-agent/test/claude-sdk-oauth-failover.test.ts +++ b/packages/coding-agent/test/claude-sdk-oauth-failover.test.ts @@ -52,6 +52,16 @@ describe("Claude SDK OAuth failover", () => { expect(classifySdkError("HTTP 529 overloaded")).toEqual({ kind: "overloaded", retryable: true }); }); + it.each([ + ["fetch failed; getaddrinfo ENOTFOUND platform.claude.com", "other", true], + ["authentication_failed: getaddrinfo EAI_AGAIN platform.claude.com", "other", true], + ["fetch failed; UND_ERR_CONNECT_TIMEOUT", "other", true], + ["authentication_failed: invalid_grant token revoked", "auth_error", true], + ["OAuth refresh HTTP 401 unauthorized", "auth_error", true], + ] as const)("classifies OAuth refresh failure %s", (message, kind, retryable) => { + expect(classifySdkError(message)).toEqual({ kind, retryable }); + }); + it("treats Claude Code's prose subscription limits as rate limits", () => { // Real message from the CLI on an exhausted Pro/Max plan: no SDK error code // and no HTTP status, so without prose matching it classified as @@ -85,13 +95,13 @@ describe("Claude SDK OAuth failover", () => { }); }); - it("still treats unrelated errors as non-retryable", () => { + it("keeps unrelated errors non-retryable while treating connection resets as transient", () => { // The prose matcher must not swallow ordinary failures into the retry path. expect(classifySdkError("context window exceeded for this request")).toEqual({ kind: "other", retryable: false, }); - expect(classifySdkError("connection reset by peer")).toEqual({ kind: "other", retryable: false }); + expect(classifySdkError("connection reset by peer")).toEqual({ kind: "other", retryable: true }); }); it("walks HRW order after a rate limit, persists the cooldown, and emits failover", async () => { diff --git a/packages/coding-agent/test/interactive-host-runtime.test.ts b/packages/coding-agent/test/interactive-host-runtime.test.ts index 9007544631..a76d3cc453 100644 --- a/packages/coding-agent/test/interactive-host-runtime.test.ts +++ b/packages/coding-agent/test/interactive-host-runtime.test.ts @@ -20,8 +20,8 @@ import { FooterComponent } from "../src/modes/interactive/components/footer.ts"; import { createInteractiveHostRuntime, createRemoteSessionProxy, - RemoteInteractiveRuntime, INTERACTIVE_HOST_FALLBACK_WARNING, + RemoteInteractiveRuntime, } from "../src/modes/interactive/interactive-host-runtime.ts"; import { initTheme } from "../src/modes/interactive/theme/theme.ts"; import { RpcClient } from "../src/modes/rpc/rpc-client.ts"; @@ -160,11 +160,7 @@ async function waitForHost(child: ChildProcessWithoutNullStreams, socket: string describe("interactive host runtime", () => { it("re-registers rendered capability and last width after reconnect", async () => { const setClientInfo = vi.fn(async () => {}); - const runtime = new RemoteInteractiveRuntime( - {} as AgentSessionRuntime, - {} as never, - { setClientInfo } as never, - ); + const runtime = new RemoteInteractiveRuntime({} as AgentSessionRuntime, {} as never, { setClientInfo } as never); runtime.setClientInfo(117); await Promise.resolve(); await runtime.reRegisterClientInfo(); @@ -1853,7 +1849,11 @@ describe("interactive host runtime", () => { ensureHost: async () => undefined, }); try { - await runtime.switchSession(target.getSessionFile()!); + await runtime.switchSession(target.getSessionFile()!, { + withSession: async (ctx) => { + expect(ctx.sessionManager.getSessionFile()).toBe(target.getSessionFile()); + }, + }); await runtime.session.compact(); expect(runtime.session.messages).toContainEqual({ role: "user", diff --git a/packages/coding-agent/test/rpc-socket-host.test.ts b/packages/coding-agent/test/rpc-socket-host.test.ts index 9cf81c1e9a..0caa0c52a3 100644 --- a/packages/coding-agent/test/rpc-socket-host.test.ts +++ b/packages/coding-agent/test/rpc-socket-host.test.ts @@ -367,7 +367,12 @@ describe("RPC Unix-socket multi-connection host", () => { Array.isArray(value.widgetLines), ); await expect( - peer.peer.request({ id: "client-info", type: "set_client_info", width: 80, capabilities: ["rendered_components"] }), + peer.peer.request({ + id: "client-info", + type: "set_client_info", + width: 80, + capabilities: ["rendered_components"], + }), ).resolves.toMatchObject({ type: "response", command: "set_client_info", success: true }); const opened = await peer.peer.request({ id: "open", type: "open_session", cwd: qa.cwd }); const sessionId = openedSessionId(opened); @@ -499,7 +504,18 @@ describe("RPC Unix-socket multi-connection host", () => { `export default function (pi) { pi.on("session_start", (_event, ctx) => ctx.ui.setWidget("factory", () => ({ render: (width) => [String(width)] }))); }`, ); const child = spawnRpc( - ["--mode", "rpc", "--listen", `unix://${qa.socketPath}`, "--provider", MOCK_PROVIDER, "--model", MOCK_MODEL, "--extension", join(qa.agentDir, "extensions", "widget-factory.ts")], + [ + "--mode", + "rpc", + "--listen", + `unix://${qa.socketPath}`, + "--provider", + MOCK_PROVIDER, + "--model", + MOCK_MODEL, + "--extension", + join(qa.agentDir, "extensions", "widget-factory.ts"), + ], qa, "rendered_components", ); @@ -508,13 +524,26 @@ describe("RPC Unix-socket multi-connection host", () => { try { const first = await peer.peer.request({ id: "open-a", type: "open_session", cwd: qa.cwd }); const sessionA = openedSessionId(first); - await peer.peer.request({ id: "info-a", type: "set_client_info", sessionId: sessionA, width: 80, capabilities: ["rendered_components"] }); + await peer.peer.request({ + id: "info-a", + type: "set_client_info", + sessionId: sessionA, + width: 80, + capabilities: ["rendered_components"], + }); await peer.peer.request({ id: "open-b", type: "open_session", cwd: qa.cwd }); const factoryAfterSecondOpen = peer.peer.waitFor( - (value) => value.type === "extension_ui_request" && value.widgetKey === "factory" && JSON.stringify(value.widgetLines) === JSON.stringify(["81"]), + (value) => + value.type === "extension_ui_request" && + value.widgetKey === "factory" && + JSON.stringify(value.widgetLines) === JSON.stringify(["81"]), ); await peer.peer.request({ id: "info-a-again", type: "set_client_info", sessionId: sessionA, width: 81 }); - expect(await factoryAfterSecondOpen).toMatchObject({ sessionId: sessionA, widgetKey: "factory", widgetLines: ["81"] }); + expect(await factoryAfterSecondOpen).toMatchObject({ + sessionId: sessionA, + widgetKey: "factory", + widgetLines: ["81"], + }); } finally { peer.peer.close(); peer.socket.destroy(); diff --git a/packages/coding-agent/test/suite/claude-sdk-oauth-extension.test.ts b/packages/coding-agent/test/suite/claude-sdk-oauth-extension.test.ts index 7e2da39d41..bb68ba34b8 100644 --- a/packages/coding-agent/test/suite/claude-sdk-oauth-extension.test.ts +++ b/packages/coding-agent/test/suite/claude-sdk-oauth-extension.test.ts @@ -55,19 +55,17 @@ async function createRuntimeWithProvider(config: ProviderConfigInput, storage = return runtime; } -function authenticatedStorage(): AuthStorage { +function authenticatedStorage(accountCount = 1): AuthStorage { return AuthStorage.inMemory({ [CLAUDE_SDK_OAUTH_PROVIDER_ID]: { ...emptyCredential(), - accounts: [ - { - name: "test", - refresh: "test-refresh", - access: "test-access", - expires: Date.now() + 60_000, - source: "login", - }, - ], + accounts: Array.from({ length: accountCount }, (_, index) => ({ + name: `test-${index + 1}`, + refresh: `test-refresh-${index + 1}`, + access: `test-access-${index + 1}`, + expires: Date.now() + 60_000, + source: "login", + })), }, }); } @@ -146,7 +144,7 @@ describe("claude-sdk-oauth builtin provider", () => { }); }); - it("preflight reaches streamSimple with a stored login", async () => { + it("preflight reaches streamSimple with multiple stored logins", async () => { const { registration } = captureRegistration(); let called = false; const config: ProviderConfigInput = { @@ -156,7 +154,7 @@ describe("claude-sdk-oauth builtin provider", () => { return fakeStreamSimple()(model, context); }, }; - const runtime = await createRuntimeWithProvider(config, authenticatedStorage()); + const runtime = await createRuntimeWithProvider(config, authenticatedStorage(2)); const model = (await runtime.getAvailable(CLAUDE_SDK_OAUTH_PROVIDER_ID))[0]; expect(model).toBeDefined(); const stream = runtime.streamSimple(model as Model, { messages: [], tools: [] } as unknown as Context);