From e2ccdc85092f95d053cdd360da461012db1760a1 Mon Sep 17 00:00:00 2001 From: Vegard Stikbakke Date: Wed, 1 Jul 2026 13:47:47 +0200 Subject: [PATCH] fix(ai): delay Copilot device-code token polling closes #6187 --- packages/ai/CHANGELOG.md | 1 + packages/ai/src/utils/oauth/device-code.ts | 8 +++++ packages/ai/src/utils/oauth/github-copilot.ts | 1 + packages/ai/test/github-copilot-oauth.test.ts | 32 +++++++++++++------ packages/ai/test/oauth-device-code.test.ts | 23 +++++++++++++ 5 files changed, 55 insertions(+), 10 deletions(-) diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index 03c92e6da..860618243 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -4,6 +4,7 @@ ### Fixed +- Fixed GitHub Copilot device-code login polling to wait before the first token poll, avoiding incorrect device-code failures for some users after browser authorization ([#6187](https://github.com/earendil-works/pi/issues/6187)). - Fixed OpenAI Codex user-agent construction to synchronously load Node OS metadata, avoiding a startup race that could report `pi (browser)` in Node/Bun. - Fixed Fireworks GLM 5.2 Fast to use the OpenAI-compatible endpoint and `thinkingLevelMap`, aligning it with GLM 5.2 ([#6195](https://github.com/earendil-works/pi/issues/6195)). diff --git a/packages/ai/src/utils/oauth/device-code.ts b/packages/ai/src/utils/oauth/device-code.ts index 1121136f0..3c1de92f8 100644 --- a/packages/ai/src/utils/oauth/device-code.ts +++ b/packages/ai/src/utils/oauth/device-code.ts @@ -18,6 +18,7 @@ export type OAuthDeviceCodePollResult = OAuthDeviceCodeIncompletePollResult | export type OAuthDeviceCodePollOptions = { intervalSeconds?: number; expiresInSeconds?: number; + waitBeforeFirstPoll?: boolean; poll: () => Promise>; signal?: AbortSignal; }; @@ -53,6 +54,13 @@ export async function pollOAuthDeviceCodeFlow(options: OAuthDeviceCodePollOpt ); let slowDownResponses = 0; + if (options.waitBeforeFirstPoll) { + const remainingMs = deadline - Date.now(); + if (remainingMs > 0) { + await abortableSleep(Math.min(intervalMs, remainingMs), options.signal, CANCEL_MESSAGE); + } + } + while (Date.now() < deadline) { if (options.signal?.aborted) { throw new Error(CANCEL_MESSAGE); diff --git a/packages/ai/src/utils/oauth/github-copilot.ts b/packages/ai/src/utils/oauth/github-copilot.ts index 111af0adb..9f03a139c 100644 --- a/packages/ai/src/utils/oauth/github-copilot.ts +++ b/packages/ai/src/utils/oauth/github-copilot.ts @@ -207,6 +207,7 @@ async function pollForGitHubAccessToken( return pollOAuthDeviceCodeFlow({ intervalSeconds: device.interval, expiresInSeconds: device.expires_in, + waitBeforeFirstPoll: true, signal, poll: async () => { const raw = await fetchJson(urls.accessTokenUrl, { diff --git a/packages/ai/test/github-copilot-oauth.test.ts b/packages/ai/test/github-copilot-oauth.test.ts index f5e426e33..4af187fa5 100644 --- a/packages/ai/test/github-copilot-oauth.test.ts +++ b/packages/ai/test/github-copilot-oauth.test.ts @@ -239,7 +239,7 @@ describe("GitHub Copilot OAuth device flow", () => { await loginPromise; }); - it("polls immediately and increases the interval after slow_down", async () => { + it("waits before polling and increases the interval after slow_down", async () => { vi.useFakeTimers(); const startTime = new Date("2026-03-09T00:00:00Z"); vi.setSystemTime(startTime); @@ -315,6 +315,12 @@ describe("GitHub Copilot OAuth device flow", () => { }); await vi.advanceTimersByTimeAsync(0); + expect(accessTokenPollTimes).toHaveLength(0); + + await vi.advanceTimersByTimeAsync(4999); + expect(accessTokenPollTimes).toHaveLength(0); + + await vi.advanceTimersByTimeAsync(1); expect(accessTokenPollTimes).toHaveLength(1); await vi.advanceTimersByTimeAsync(4999); @@ -330,9 +336,9 @@ describe("GitHub Copilot OAuth device flow", () => { await loginPromise; expect(accessTokenPollTimes).toEqual([ - startTime.getTime(), startTime.getTime() + 5000, - startTime.getTime() + 15000, + startTime.getTime() + 10000, + startTime.getTime() + 20000, ]); }); @@ -383,18 +389,24 @@ describe("GitHub Copilot OAuth device flow", () => { /Device flow timed out after one or more slow_down responses/, ); - await vi.advanceTimersByTimeAsync(5000); - expect(accessTokenPollTimes).toEqual([startTime.getTime()]); + await vi.advanceTimersByTimeAsync(4999); + expect(accessTokenPollTimes).toEqual([]); - await vi.advanceTimersByTimeAsync(5000); - expect(accessTokenPollTimes).toEqual([startTime.getTime(), startTime.getTime() + 10000]); + await vi.advanceTimersByTimeAsync(1); + expect(accessTokenPollTimes).toEqual([startTime.getTime() + 5000]); - await vi.advanceTimersByTimeAsync(14999); - expect(accessTokenPollTimes).toEqual([startTime.getTime(), startTime.getTime() + 10000]); + await vi.advanceTimersByTimeAsync(9999); + expect(accessTokenPollTimes).toEqual([startTime.getTime() + 5000]); + + await vi.advanceTimersByTimeAsync(1); + expect(accessTokenPollTimes).toEqual([startTime.getTime() + 5000, startTime.getTime() + 15000]); + + await vi.advanceTimersByTimeAsync(9999); + expect(accessTokenPollTimes).toEqual([startTime.getTime() + 5000, startTime.getTime() + 15000]); await vi.advanceTimersByTimeAsync(1); await rejection; - expect(accessTokenPollTimes).toEqual([startTime.getTime(), startTime.getTime() + 10000]); + expect(accessTokenPollTimes).toEqual([startTime.getTime() + 5000, startTime.getTime() + 15000]); }); }); diff --git a/packages/ai/test/oauth-device-code.test.ts b/packages/ai/test/oauth-device-code.test.ts index f32decb6d..7b5126790 100644 --- a/packages/ai/test/oauth-device-code.test.ts +++ b/packages/ai/test/oauth-device-code.test.ts @@ -38,6 +38,29 @@ describe("OAuth device-code polling", () => { ]); }); + it("can wait before the first poll", async () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-03-09T00:00:00Z")); + + const pollTimes: number[] = []; + const resultPromise = pollOAuthDeviceCodeFlow({ + intervalSeconds: 2, + expiresInSeconds: 30, + waitBeforeFirstPoll: true, + poll: async () => { + pollTimes.push(Date.now()); + return { status: "complete" as const, value: "token" }; + }, + }); + + await vi.advanceTimersByTimeAsync(1999); + expect(pollTimes).toEqual([]); + + await vi.advanceTimersByTimeAsync(1); + await expect(resultPromise).resolves.toBe("token"); + expect(pollTimes).toEqual([new Date("2026-03-09T00:00:02Z").getTime()]); + }); + it("cancels an in-flight wait", async () => { vi.useFakeTimers(); const controller = new AbortController();