diff --git a/ui/src/components/OnboardingWizard.adapters.test.tsx b/ui/src/components/OnboardingWizard.adapters.test.tsx index f0ffcbc5306..13378d0120f 100644 --- a/ui/src/components/OnboardingWizard.adapters.test.tsx +++ b/ui/src/components/OnboardingWizard.adapters.test.tsx @@ -111,7 +111,7 @@ async function flushReact() { }); } -async function mount() { +async function mount(nativeRunnerEnabled?: boolean) { const container = document.createElement("div"); document.body.appendChild(container); const root = createRoot(container); @@ -124,6 +124,7 @@ async function mount() { session: { id: "session-1", userId: "user-1" }, user: { id: "user-1", name: "Example", email: "user-1@example.com", image: null }, }); + if (nativeRunnerEnabled !== undefined) queryClient.setQueryData(queryKeys.instance.experimentalSettings, { enableNativeRunner: nativeRunnerEnabled, enableOpenAiDot: true }); await act(async () => { root.render( @@ -288,10 +289,19 @@ describe("OnboardingWizard adapter selection", () => { }); }); + it("does not offer or retain Copilot when only OpenAI Dot is enabled", async () => { + mockAdapterRegistry.list = [{ type: "paperclip_runner" }, { type: "codex_local" }]; + window.localStorage.setItem(ONBOARDING_STORAGE_KEY, JSON.stringify({ step: 0, adapterType: "paperclip_runner", onboardingCopilot: true, model: "gpt-5.6-luna" })); + const { root, container } = await mount(false); + expect(JSON.parse(window.localStorage.getItem(ONBOARDING_STORAGE_KEY)!)).toMatchObject({ adapterType: "codex_local", onboardingCopilot: false, model: "" }); + expect(container.textContent).not.toContain("GitHub Copilot"); + await act(async () => root.unmount()); + }); + it("restores a qualified Copilot draft without changing its explicit model", async () => { mockAdapterRegistry.list = [{ type: "paperclip_runner" }]; window.localStorage.setItem(ONBOARDING_STORAGE_KEY, JSON.stringify({ step: 0, adapterType: "paperclip_runner", onboardingCopilot: true, model: "gpt-5.6-luna" })); - const { root } = await mount(); + const { root } = await mount(true); expect(JSON.parse(window.localStorage.getItem(ONBOARDING_STORAGE_KEY)!)).toMatchObject({ adapterType: "paperclip_runner", onboardingCopilot: true, model: "gpt-5.6-luna" }); await act(async () => root.unmount()); }); diff --git a/ui/src/components/OnboardingWizard.tsx b/ui/src/components/OnboardingWizard.tsx index 4fc9678a358..40d51e31e11 100644 --- a/ui/src/components/OnboardingWizard.tsx +++ b/ui/src/components/OnboardingWizard.tsx @@ -959,6 +959,7 @@ function OnboardingWizardInner({ queryFn: () => instanceSettingsApi.getExperimental(), enabled: effectiveOnboardingOpen && step >= 3 && step <= 5, }); + const copilotOnboardingEnabled = copilotOnboardingQualified && experimentalSettingsForLogin?.enableNativeRunner === true; const loginEnvironmentResolution = useMemo(() => { try { return { environmentId: resolveAdapterTestEnvironmentId({ @@ -1159,14 +1160,14 @@ function OnboardingWizardInner({ ) .map((a) => ({ ...getAdapterDisplay(a.type), type: a.type })); - const copilot = copilotOnboardingQualified && !disabledTypes.has("paperclip_runner") && listUIAdapters().some((a) => a.type === "paperclip_runner") + const copilot = copilotOnboardingEnabled && !disabledTypes.has("paperclip_runner") && listUIAdapters().some((a) => a.type === "paperclip_runner") ? [{ ...getAdapterDisplay("copilot_runtime"), type: "paperclip_runner", label: "GitHub Copilot", recommended: true }] : []; return { recommendedAdapters: [...all.filter((a) => a.recommended), ...copilot], moreAdapters: all.filter((a) => !a.recommended), }; - }, [disabledTypes]); + }, [disabledTypes, copilotOnboardingEnabled]); /** * A source chosen from the visible row. Read off the row rather than off @@ -1501,14 +1502,18 @@ function OnboardingWizardInner({ // registered once the adapters query resolves, so before that a saved // external adapter is indistinguishable from a disabled one - and snapping // would replace the customer's choice with a built-in and persist it. - if (!adapterRegistryLoaded) return; + if (!adapterRegistryLoaded || (adapterType === "paperclip_runner" && !loginPolicyReady)) return; const visible = [...recommendedAdapters, ...moreAdapters].filter( (a) => !a.comingSoon, ); - if (visible.length === 0) return; + if (visible.length === 0) { + if (adapterType === "paperclip_runner" && !copilotOnboardingEnabled) { setAdapterType("claude_local"); setModel(""); setCopilotConnection(undefined); setSourcePicked(false); } + return; + } if (visible.some((a) => a.type === adapterType)) return; const next = (visible.find((a) => a.type !== "paperclip_runner") ?? visible[0]).type as AdapterType; setAdapterType(next); + if (adapterType === "paperclip_runner") { setModel(""); setCopilotConnection(undefined); } // The snap is not a choice. It replaces a name the customer can no longer // see with the first one they can, which is the right thing to hold — but // holding it *as chosen* would put a filled tile and an open sign-in panel @@ -1530,7 +1535,7 @@ function OnboardingWizardInner({ return; } setModel(""); - }, [adapterRegistryLoaded, recommendedAdapters, moreAdapters, adapterType]); + }, [adapterRegistryLoaded, recommendedAdapters, moreAdapters, adapterType, loginPolicyReady, copilotOnboardingEnabled]); const COMMAND_PLACEHOLDERS: Record = { claude_local: "claude", @@ -2039,7 +2044,7 @@ function OnboardingWizardInner({ // doesn't hire a second agent. async function handleGiveHeartbeat() { if (!createdCompanyId) return; - if (isCopilot && (!copilotOnboardingQualified || !sourceSelected || !copilotModelReady)) { + if (isCopilot && (!copilotOnboardingEnabled || !sourceSelected || !copilotModelReady)) { setError("Connect GitHub Copilot and select an available model before creating this agent."); return; } diff --git a/ui/src/components/new-agent/NewAgentSetup.tsx b/ui/src/components/new-agent/NewAgentSetup.tsx index c4765acc6e4..0ecc85d44bf 100644 --- a/ui/src/components/new-agent/NewAgentSetup.tsx +++ b/ui/src/components/new-agent/NewAgentSetup.tsx @@ -864,7 +864,9 @@ function Setup({ } {!connectionAdapter && aiProviderForAdapter(brandType) && ( { binding.mode !== "router" && setRuntimeAiBinding(binding); resetTest(); }} /> + onChange={binding => { binding.mode !== "router" && setRuntimeAiBinding(binding); resetTest(); + if (brandType === "copilot_runtime") void cache.invalidateQueries({ queryKey: ["agents", companyId, "adapter-models", brandType] }); + }} /> )} {(connectionModels ? connectionModels.error : models.error) &&

Could not load models. Retry or enter a model ID manually.

} {((showModel && !usingKimiApi) || @@ -875,8 +877,8 @@ function Setup({ presentation="native" models={connectionModels?.models ?? models.data ?? []} loadingModels={connectionModels?.isLoading ?? models.isLoading} - onRefreshModels={connectionModels?.refreshModels} - refreshingModels={connectionModels?.refreshing} + onRefreshModels={connectionModels?.refreshModels ?? (brandType === "copilot_runtime" ? async () => { await models.refetch(); } : undefined)} + refreshingModels={connectionModels?.refreshing ?? (brandType === "copilot_runtime" ? models.isFetching : undefined)} value={model} onChange={(value) => { setModel(connectionModels?.resolveModel(value) ?? value); diff --git a/ui/src/pages/NewAgent.test.tsx b/ui/src/pages/NewAgent.test.tsx index 51e9b1794fc..932c5a848f8 100644 --- a/ui/src/pages/NewAgent.test.tsx +++ b/ui/src/pages/NewAgent.test.tsx @@ -157,6 +157,7 @@ const pass = { }; beforeEach(() => { vi.clearAllMocks(); + managedApi.setDefault.mockResolvedValue({}); managedApi.list.mockResolvedValue({ currentUserId: "user-1", canManageConnections: true, connections: [] }); container = document.createElement("div"); document.body.append(container); @@ -200,6 +201,41 @@ afterEach(async () => { container.remove(); }); describe("New agent setup", () => { + it("discovers models after the first Copilot token becomes the responsible user's default", async () => { + let defaultSaved = false; + managedApi.setDefault.mockImplementation(async () => { defaultSaved = true; return {}; }); + api.adapterModels.mockImplementation(async () => { + if (!defaultSaved) throw new ApiError("Connect Copilot first", 422, null); + return [{ id: "gpt-5.6-luna", label: "GPT" }]; + }); + await render("paperclip_runner", "copilot"); + const beforeConnection = api.adapterModels.mock.calls.length; + expect(beforeConnection).toBeGreaterThan(0); + const trigger = document.querySelector('[role="combobox"][aria-label="Connection"]')!; + await act(async () => trigger.dispatchEvent(new KeyboardEvent("keydown", { key: "ArrowDown", bubbles: true }))); + await settle(); + const option = [...document.querySelectorAll('[role="option"]')].find(item => item.textContent?.includes("Connect an account")); + expect(option).toBeDefined(); + await act(async () => option!.click()); + await settle(); + const input = document.querySelector('[aria-label="API key"]')!; + expect(input).toBeTruthy(); + await act(async () => { + Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")!.set!.call(input, "github_pat_fixture_first"); + input.dispatchEvent(new Event("input", { bubbles: true })); + }); + const save = [...document.querySelectorAll('[role="dialog"] button')].find(button => button.textContent?.trim() === "Connect")!; + expect(save).toBeTruthy(); + await act(async () => save.click()); + await settle(); + expect(managedApi.setDefault).toHaveBeenCalledWith("company-1", "managed-grant"); + expect(api.adapterModels).toHaveBeenCalledTimes(beforeConnection + 1); + expect(api.adapterModels.mock.invocationCallOrder[beforeConnection]).toBeGreaterThan(managedApi.setDefault.mock.invocationCallOrder[0]); + expect(api.adapterModels).toHaveBeenLastCalledWith("company-1", "paperclip_runner", expect.objectContaining({ + provider: "acpx", acpxAgent: "copilot", aiConnection: { provider: "github", method: "api_key", mode: "responsible_user" }, + })); + }); + it("creates Dot through its independent choice without CLI or model setup, then routes to pairing", async () => { settings.getExperimental.mockResolvedValue({ enableNativeRunner: false, enableOpenAiDot: true, enablePublicMcp: true }); await render("paperclip_runner", "openai_dot");