From 04a529fec048e18f2edbaa6e6c3e07a814f7ce51 Mon Sep 17 00:00:00 2001 From: stuffbucket <231133237+stuffbucket@users.noreply.github.com> Date: Wed, 24 Jun 2026 12:15:30 -0700 Subject: [PATCH 1/2] fix(auth): prime models cache after device-flow sign-in --- src/lib/auth-controller.ts | 16 +++++++++++ tests/auth-controller.test.ts | 50 +++++++++++++++++++++++++++++++++++ 2 files changed, 66 insertions(+) diff --git a/src/lib/auth-controller.ts b/src/lib/auth-controller.ts index 86b31b9..4b03763 100644 --- a/src/lib/auth-controller.ts +++ b/src/lib/auth-controller.ts @@ -58,6 +58,7 @@ import { registerProcessCleanup } from "./process-cleanup" import { emitAuthChanged, registerAuthStatusProjector } from "./settings-events" import { clearLastUpstreamRejection, state } from "./state" import { setupCopilotToken, stopCopilotRefreshLoop } from "./token" +import { cacheModels } from "./utils" // Auth events go to the console AND a dated `auth-*.log` so they're observable // after the fact (sign-in, degrade, refresh failures, sign-out) instead of @@ -509,6 +510,21 @@ async function runPoller(flow: ActiveFlow): Promise { // signOut() may have fired and wiped the token. Don't latch signed-in over // a just-cleared session. if (flow.abort.signal.aborted) return + + // Prime the models cache so the catalog is populated the moment sign-in + // completes. The cold-boot path (bootstrap.ts) does this after + // setupCopilotToken; the device-flow path must too. The lazy + // stale-refresh middleware only REVALIDATES an already-primed cache + // (`loadedAtMs === null` → "not_primed", no-op), so on a fresh install — + // boot has no token, so boot never primes — without an explicit prime + // here the models list stays empty until a forced refresh. Best-effort: + // a models-fetch failure must not fail sign-in (mirrors setupCopilotToken). + try { + await cacheModels() + } catch (err) { + log.warn("Auth-controller: failed to cache models after sign-in:", err) + } + setAuthState({ kind: "signed-in", login, diff --git a/tests/auth-controller.test.ts b/tests/auth-controller.test.ts index 76a2a2f..326da15 100644 --- a/tests/auth-controller.test.ts +++ b/tests/auth-controller.test.ts @@ -50,6 +50,8 @@ const harness = { addAccountCalls: [] as Array, setupCopilotTokenImpl: (): Promise => Promise.resolve(), setupCopilotTokenCalls: 0, + cacheModelsImpl: (): Promise => Promise.resolve(), + cacheModelsCalls: 0, deactivateCalls: 0, markNeedsReauthCalls: [] as Array<{ status: number | null @@ -69,6 +71,7 @@ const realGetDeviceCodeModule = await import("~/services/github/get-device-code") const realGetUserModule = await import("~/services/github/get-user") const realTokenModule = await import("~/lib/token") +const realUtilsModule = await import("~/lib/utils") const realFsPromisesModule = await import("node:fs/promises") void mock.module("~/services/github/get-device-code", () => ({ @@ -88,6 +91,17 @@ void mock.module("~/lib/token", () => ({ stopCopilotRefreshLoop: () => {}, })) +// Spread the real namespace so the many OTHER utils exports (getUUID, +// parseUserIdMetadata, sleep, …) survive; only count/stub cacheModels so +// the sign-in success path doesn't make a real Copilot /models fetch. +void mock.module("~/lib/utils", () => ({ + ...realUtilsModule, + cacheModels: () => { + harness.cacheModelsCalls++ + return harness.cacheModelsImpl() + }, +})) + // Spread the real namespace so `readFile` / `writeFile` / etc. survive // the override — `tests/github-token-store.test.ts` reads/writes via // the same module and gets undefined functions otherwise. @@ -113,6 +127,7 @@ afterAll(() => { ) void mock.module("~/services/github/get-user", () => realGetUserModule) void mock.module("~/lib/token", () => realTokenModule) + void mock.module("~/lib/utils", () => realUtilsModule) void mock.module("node:fs/promises", () => realFsPromisesModule) }) @@ -172,6 +187,8 @@ beforeEach(() => { harness.pollAccessTokenCalls = 0 harness.setupCopilotTokenImpl = () => Promise.resolve() harness.setupCopilotTokenCalls = 0 + harness.cacheModelsImpl = () => Promise.resolve() + harness.cacheModelsCalls = 0 }) afterEach(() => { @@ -228,6 +245,39 @@ describe("getAuthStatus", () => { } }) + test("primes the models cache after a successful device-flow sign-in", async () => { + // Regression: the cold-boot path (bootstrap) calls cacheModels() after + // minting the Copilot token, but the device-flow sign-in path did not. + // On a fresh install (boot has no token → boot never primes), the lazy + // stale-refresh middleware can't help (it no-ops on an unprimed cache), + // so the models list stayed empty until a forced refresh. Sign-in must + // prime it itself. + const poll = deferred() + harness.pollAccessTokenImpl = () => poll.promise + + await startDeviceFlow() + poll.resolve("ghu_ok") + await flushMicrotasks(10) + + expect(getAuthStatus().state).toBe("authenticated") + expect(harness.cacheModelsCalls).toBe(1) + }) + + test("a models-cache failure does not fail sign-in (best-effort)", async () => { + // cacheModels is best-effort: a Copilot /models hiccup must not block the + // user from reaching the signed-in state. + harness.cacheModelsImpl = () => Promise.reject(new Error("models 503")) + const poll = deferred() + harness.pollAccessTokenImpl = () => poll.promise + + await startDeviceFlow() + poll.resolve("ghu_ok") + await flushMicrotasks(10) + + expect(harness.cacheModelsCalls).toBe(1) + expect(getAuthStatus().state).toBe("authenticated") + }) + test("returns { state: 'unauthenticated' } literal when no token and no flow", () => { const status = getAuthStatus() expect(status).toEqual({ state: "unauthenticated" }) From 52e8503ad60c142a45bc206a4b78cd4fd268bcbf Mon Sep 17 00:00:00 2001 From: stuffbucket <231133237+stuffbucket@users.noreply.github.com> Date: Wed, 24 Jun 2026 12:20:02 -0700 Subject: [PATCH 2/2] test(auth): stub cacheModels in sign-in harnesses that complete a device flow --- tests/auth-controller-lifecycle.test.ts | 11 +++++++++++ tests/auth-status-contract.test.ts | 9 +++++++++ 2 files changed, 20 insertions(+) diff --git a/tests/auth-controller-lifecycle.test.ts b/tests/auth-controller-lifecycle.test.ts index 3340ccd..04a3b5d 100644 --- a/tests/auth-controller-lifecycle.test.ts +++ b/tests/auth-controller-lifecycle.test.ts @@ -65,6 +65,7 @@ const realGetDeviceCodeModule = await import("~/services/github/get-device-code") const realGetUserModule = await import("~/services/github/get-user") const realTokenModule = await import("~/lib/token") +const realUtilsModule = await import("~/lib/utils") const realFsPromisesModule = await import("node:fs/promises") void mock.module("~/services/github/get-device-code", () => ({ @@ -84,6 +85,15 @@ void mock.module("~/lib/token", () => ({ stopCopilotRefreshLoop: () => {}, })) +// Sign-in now primes the models cache (cacheModels) after minting the +// Copilot token. Stub it so completing a device flow doesn't make a real +// Copilot /models fetch; spread the real namespace so the other utils +// exports survive. +void mock.module("~/lib/utils", () => ({ + ...realUtilsModule, + cacheModels: () => Promise.resolve(), +})) + // Spread the real namespace so `readFile` / `writeFile` / etc. survive // the override — `tests/github-token-store.test.ts` reads/writes via // the same module and gets undefined functions otherwise. @@ -109,6 +119,7 @@ afterAll(() => { ) void mock.module("~/services/github/get-user", () => realGetUserModule) void mock.module("~/lib/token", () => realTokenModule) + void mock.module("~/lib/utils", () => realUtilsModule) void mock.module("node:fs/promises", () => realFsPromisesModule) }) diff --git a/tests/auth-status-contract.test.ts b/tests/auth-status-contract.test.ts index cb099a4..e3e3f57 100644 --- a/tests/auth-status-contract.test.ts +++ b/tests/auth-status-contract.test.ts @@ -53,6 +53,7 @@ const realGetDeviceCodeModule = await import("~/services/github/get-device-code") const realGetUserModule = await import("~/services/github/get-user") const realTokenModule = await import("~/lib/token") +const realUtilsModule = await import("~/lib/utils") void mock.module("~/services/github/get-device-code", () => ({ getDeviceCode: () => @@ -74,6 +75,13 @@ void mock.module("~/lib/token", () => ({ setupCopilotToken: () => Promise.resolve(), })) +// Sign-in primes the models cache after minting the Copilot token; stub it +// so completing a device flow doesn't make a real Copilot /models fetch. +void mock.module("~/lib/utils", () => ({ + ...realUtilsModule, + cacheModels: () => Promise.resolve(), +})) + afterAll(() => { void mock.module( "~/services/github/get-device-code", @@ -81,6 +89,7 @@ afterAll(() => { ) void mock.module("~/services/github/get-user", () => realGetUserModule) void mock.module("~/lib/token", () => realTokenModule) + void mock.module("~/lib/utils", () => realUtilsModule) }) const {