diff --git a/src/providers/github-copilot-auto.ts b/src/providers/github-copilot-auto.ts index e42347342be..1d927525fb7 100644 --- a/src/providers/github-copilot-auto.ts +++ b/src/providers/github-copilot-auto.ts @@ -135,7 +135,10 @@ export async function resolveCopilotAuto(provider: OcxProviderConfig, requestedM // control-plane send. That is not a client abort: fall back to the requested model // so the dispatch path revalidates the selection and rebuilds on the new account. if (error instanceof ProviderOutboundSendCancelledError) { - return { provider: transport, modelId: requestedModel, auto: false }; + // expiresAt 0: this selection was never negotiated. request-transport fences the physical + // send on it, so dispatch always renegotiates instead of sending the bare requested model + // if the selection becomes current again before the send. + return { provider: transport, modelId: requestedModel, auto: false, expiresAt: 0 }; } // Permission discovery is additive for legacy paid configurations. A temporary // discovery outage must not turn a previously usable named route into a refusal. @@ -148,7 +151,7 @@ export async function resolveCopilotAuto(provider: OcxProviderConfig, requestedM // new account instead of failing the whole negotiation. A client abort still rejects. const fallBackOnSwitch = (error: unknown) => { if (!signal?.aborted && error instanceof ProviderOutboundSendCancelledError) - return { provider: transport, modelId: requestedModel, auto: false } as const; + return { provider: transport, modelId: requestedModel, auto: false, expiresAt: 0 } as const; throw error; }; let session: Awaited>; diff --git a/tests/providers/github-copilot/github-copilot-auto.test.ts b/tests/providers/github-copilot/github-copilot-auto.test.ts index ef9cf814699..0a6f40a23b1 100644 --- a/tests/providers/github-copilot/github-copilot-auto.test.ts +++ b/tests/providers/github-copilot/github-copilot-auto.test.ts @@ -196,7 +196,9 @@ describe("Copilot Auto permissions and negotiation", () => { calls++; throw new Error("network unavailable"); }) as typeof fetch } as OcxProviderConfig; expect((await resolveCopilotAuto(provider, "named", parsed)).modelId).toBe("named"); - expect(calls).toBe(1); + // An outage fallback is a usable named route, not a lapsed negotiation: no forced rebuild. + expect((await resolveCopilotAuto(provider, "named", parsed)).expiresAt).toBeUndefined(); + expect(calls).toBe(2); }); for (const cancelAt of ["/models/session", "/models/session/intent"] as const) { test(`an authority change during ${cancelAt} also falls back instead of failing negotiation`, async () => { @@ -214,8 +216,11 @@ describe("Copilot Auto permissions and negotiation", () => { if (seen.length === (cancelAt === "/models/session" ? 1 : 2)) allowed = false; return allowed; }; - await expect(resolveCopilotAuto(provider, "auto", parsed, undefined, beforeSend)) - .resolves.toMatchObject({ modelId: "auto", auto: false }); + const selected = await resolveCopilotAuto(provider, "auto", parsed, undefined, beforeSend); + expect(selected).toMatchObject({ modelId: "auto", auto: false }); + // The fallback was never negotiated. request-transport fences dispatch on expiresAt, so an + // already-lapsed value makes dispatch renegotiate even if the selection becomes current again. + expect(selected.expiresAt).toBeLessThanOrEqual(Date.now()); expect(seen.some(path => path.endsWith(cancelAt))).toBe(false); }); } @@ -227,8 +232,9 @@ describe("Copilot Auto permissions and negotiation", () => { await expect(resolveCopilotAuto(provider, "auto", parsed, AbortSignal.abort())).rejects.toThrow(); // A manual account switch cancels the queued control-plane send. That is not a client // abort: the caller revalidates the selection and rebuilds before dispatch. - await expect(resolveCopilotAuto(provider, "auto", parsed, undefined, () => false)) - .resolves.toMatchObject({ modelId: "auto", auto: false }); + const selected = await resolveCopilotAuto(provider, "auto", parsed, undefined, () => false); + expect(selected).toMatchObject({ modelId: "auto", auto: false }); + expect(selected.expiresAt).toBeLessThanOrEqual(Date.now()); expect(sends).toBe(0); }); });