diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index d5468403f..9b180d591 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Fixed + +- Fixed new sessions intermittently ignoring the saved default model, or warning that no models are available, when it belongs to an extension-registered native provider with a stored credential ([#9962](https://github.com/earendil-works/pi/issues/9962)). + ## [0.99.1] - 2026-09-29 ### New Features diff --git a/packages/coding-agent/src/core/model-runtime.ts b/packages/coding-agent/src/core/model-runtime.ts index 47663aed6..7edd44048 100644 --- a/packages/coding-agent/src/core/model-runtime.ts +++ b/packages/coding-agent/src/core/model-runtime.ts @@ -885,9 +885,37 @@ export class ModelRuntime implements Models { this.nativeExtensionProviders.set(provider.id, provider); this.recomposeProvider(provider.id); this.updateModelSnapshot(); + this.markProvisionallyConfigured( + provider.id, + configuredRequestAuthStatus(this.config.getProvider(provider.id), undefined), + provider.auth.oauth && !provider.auth.apiKey ? "oauth" : "api_key", + ); void this.refresh({ allowNetwork: false }); } + /** + * Mark a newly registered provider as configured when it has a stored credential or a configured + * API key. Availability checks run asynchronously, and callers such as initial model selection + * read the snapshot before they finish. The next availability pass replaces this entry. + */ + private markProvisionallyConfigured( + providerId: string, + configuredStatus: AuthStatus | undefined, + type: AuthType, + ): void { + if (!this.snapshot.storedProviders.has(providerId) && !configuredStatus?.configured) return; + const configuredProviders = new Set(this.snapshot.configuredProviders).add(providerId); + const auth = new Map(this.snapshot.auth); + // Never clobber a real check result. + if (!auth.get(providerId)) auth.set(providerId, { type, source: "configured provider" }); + this.snapshot = { + ...this.snapshot, + auth, + configuredProviders, + available: this.snapshot.all.filter((model) => configuredProviders.has(model.provider)), + }; + } + registerProvider(providerId: string, config: ProviderConfigInput): void { // Validate the incoming registration on its own, like the legacy registry: // a broken re-registration must throw without touching the stored config. @@ -903,26 +931,11 @@ export class ModelRuntime implements Models { this.extensionProviders.set(providerId, effective); this.recomposeProvider(providerId); this.updateModelSnapshot(); - if ( - this.snapshot.storedProviders.has(providerId) || - configuredRequestAuthStatus(this.config.getProvider(providerId), effective)?.configured - ) { - const configuredProviders = new Set(this.snapshot.configuredProviders).add(providerId); - const auth = new Map(this.snapshot.auth); - // Provisional entry until the async refresh lands; never clobber a real check result. - if (!auth.get(providerId)) { - auth.set(providerId, { - type: effective.oauth && !effective.apiKey ? "oauth" : "api_key", - source: "configured provider", - }); - } - this.snapshot = { - ...this.snapshot, - auth, - configuredProviders, - available: this.snapshot.all.filter((model) => configuredProviders.has(model.provider)), - }; - } + this.markProvisionallyConfigured( + providerId, + configuredRequestAuthStatus(this.config.getProvider(providerId), effective), + effective.oauth && !effective.apiKey ? "oauth" : "api_key", + ); void this.refresh({ allowNetwork: false }); } diff --git a/packages/coding-agent/test/model-runtime-modify-models-compat.test.ts b/packages/coding-agent/test/model-runtime-modify-models-compat.test.ts index f30005131..db7d89515 100644 --- a/packages/coding-agent/test/model-runtime-modify-models-compat.test.ts +++ b/packages/coding-agent/test/model-runtime-modify-models-compat.test.ts @@ -91,6 +91,50 @@ describe("extension provider model lifecycle", () => { expect(registry.getProvider("extension-native")).toBeUndefined(); }); + // Regression for #9962: initial model selection reads the snapshot before the async refresh finishes. + it("marks a native provider with a stored credential as configured when it registers", async () => { + const runtime = await ModelRuntime.create({ + credentials: AuthStorage.inMemory({ + "extension-native": { + type: "oauth", + access: "access", + refresh: "refresh", + expires: Date.now() + 3_600_000, + }, + }), + modelsStore: new InMemoryModelsStore(), + modelsPath: null, + allowModelNetwork: false, + }); + const nativeModel = { ...model("native"), provider: "extension-native" }; + const unused = () => { + throw new Error("unused"); + }; + const provider: Provider = { + id: "extension-native", + name: "Extension Native", + auth: { + oauth: { + name: "Native OAuth", + login: unused, + refresh: async (credential) => credential, + toAuth: async (credential) => ({ apiKey: credential.access }), + }, + }, + getModels: () => [nativeModel], + stream: unused, + streamSimple: unused, + }; + + runtime.registerNativeProvider(provider); + + expect(runtime.hasConfiguredAuth("extension-native")).toBe(true); + expect(runtime.isUsingOAuth("extension-native")).toBe(true); + expect(runtime.getAvailableSnapshot().map((m) => `${m.provider}/${m.id}`)).toContain("extension-native/native"); + await runtime.refresh({ allowNetwork: false }); + expect(runtime.hasConfiguredAuth("extension-native")).toBe(true); + }); + it("preserves native deferred methods through provider overlays", async () => { const tempDir = mkdtempSync(join(tmpdir(), "pi-native-provider-deferred-")); const modelsPath = join(tempDir, "models.json");