Refresh first-token Copilot discovery and honor native Runner admission in onboarding

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-10-08 08:52:25 -05:00
1 parent a7212bd765
commit 7010b852e4
4 files changed
+64 -11

No files matched your search

@@ -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(
<QueryClientProvider client={queryClient}>
@@ -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());
});
+11 -6
View File
@@ -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<string, string> = {
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;
}
@@ -864,7 +864,9 @@ function Setup({
</>}
{!connectionAdapter && aiProviderForAdapter(brandType) && (
<AiConnectionField companyId={companyId} agentName={name} adapterType={brandType} model={model} environmentId={environmentId ?? undefined} value={aiBinding}
onChange={binding => { 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) && <p role="alert" className="text-sm text-destructive">Could not load models. Retry or enter a model ID manually.</p>}
{((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);
+36
View File
@@ -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<HTMLElement>('[role="option"]')].find(item => item.textContent?.includes("Connect an account"));
expect(option).toBeDefined();
await act(async () => option!.click());
await settle();
const input = document.querySelector<HTMLInputElement>('[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<HTMLButtonElement>('[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");