diff --git a/maps/agent-access-map/onboarding.md b/maps/agent-access-map/onboarding.md index 762ac1b5159..9c241a3fe7e 100644 --- a/maps/agent-access-map/onboarding.md +++ b/maps/agent-access-map/onboarding.md @@ -96,6 +96,10 @@ testing an existing agent; interactive account login additionally requires a board user and owner-bound session. An agent's right to hire is not login or instance-environment administration authority. +First-agent onboarding limits the provider picker to Claude and Codex. Other +providers remain available through normal agent creation; this UI choice does +not change adapter APIs or hiring permissions. + A name-only, held setup has no provider selection yet, so the wizard omits its runner choice until the provider is chosen. The existing board-only setup and saved-agent update permissions remain; normal runner compatibility validation diff --git a/maps/feature-map/onboarding.md b/maps/feature-map/onboarding.md index e25d45fdc73..9f04156c308 100644 --- a/maps/feature-map/onboarding.md +++ b/maps/feature-map/onboarding.md @@ -36,6 +36,20 @@ Use `//onboarding` to resume an existing company's agent setup; the unprefixed route starts at organization naming on self-hosted instances. Cloud-managed instances reuse their existing company for agent setup. +First-agent onboarding offers only Claude and Codex, subject to instance adapter +availability. GitHub Copilot and other providers remain available through Add +agent after onboarding. A saved draft for another provider requires a new choice. +Old Copilot drafts discard the retired selection and model, including when both +onboarding providers are disabled. The wizard no longer contains Copilot setup +UI, connection state, or model discovery; later Add agent retains that support. +Changing an unavailable saved provider clears its model. If both first-onboarding +providers are unavailable, the wizard explains how to enable one or use New Agent +from the Agents page for another provider. +Claude and Codex are harness choices, not a forced legacy runtime. Automatic +runner selection uses the execution environment's qualified native runner and +falls back to legacy on native Windows without changing the onboarding UI. +Windows should default to native when its runner platform support is qualified. + ### `cli-setup` Use `paperclipai onboard --help` or `paperclipai run --help` to select an isolated data directory before setup. diff --git a/ui/src/components/OnboardingWizard.adapters.test.tsx b/ui/src/components/OnboardingWizard.adapters.test.tsx index b4941d1ccdf..13b27cd3a36 100644 --- a/ui/src/components/OnboardingWizard.adapters.test.tsx +++ b/ui/src/components/OnboardingWizard.adapters.test.tsx @@ -291,19 +291,16 @@ describe("OnboardingWizard adapter selection", () => { }); }); - it("retains an explicit Copilot draft with the native runner experiment disabled", async () => { + it.each([false, true])("normalizes a saved Copilot draft with the native runner experiment set to %s", async (nativeRunnerEnabled) => { 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 } = await mount(false); - 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()); - }); - - 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(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()); + const { root } = await mount(nativeRunnerEnabled); + try { + const saved = JSON.parse(window.localStorage.getItem(ONBOARDING_STORAGE_KEY)!); + expect(saved).toMatchObject({ adapterType: "codex_local", runner: "auto", model: "" }); + expect(saved).not.toHaveProperty("onboardingCopilot"); + } finally { + await act(async () => root.unmount()); + } }); }); diff --git a/ui/src/components/OnboardingWizard.test.tsx b/ui/src/components/OnboardingWizard.test.tsx index 5184b0dd68a..b6fa3eb56a7 100644 --- a/ui/src/components/OnboardingWizard.test.tsx +++ b/ui/src/components/OnboardingWizard.test.tsx @@ -2,6 +2,7 @@ import { act, StrictMode } from "react"; import { buildClaudeLocalConfig } from "../../../packages/adapters/claude-local/src/ui/build-config"; +import { buildCodexRunnerConfig } from "../adapters/codex-local/config-fields"; import type { CreateConfigValues } from "@paperclipai/adapter-utils"; import type { LocalAiLoginStatus } from "@paperclipai/shared"; import { createRoot } from "react-dom/client"; @@ -36,15 +37,6 @@ const managedApi = vi.hoisted(() => ({ create: vi.fn(async () => ({ connectionId: "managed-connection", grantId: "managed-grant" })), })); vi.mock("@/api/ai-connections", () => ({ aiConnectionsApi: managedApi })); -// Keep the wizard tests focused on binding/model/creation boundaries. The -// connection component has its own tests; the ordinary-install UI canary -// exercises its real token storage and default selection. -vi.mock("./ai-connections/AiConnectionField", async (importOriginal) => { - const actual = await importOriginal(); - return { ...actual, AiConnectionField: (props: { environmentId?: string; onChange: (value: import("@paperclipai/shared").AiConnectionBinding) => void }) => ( - - ) }; -}); vi.mock("../api/auth", async (importOriginal) => { const actual = await importOriginal(); return { ...actual, authApi: { ...actual.authApi, getSession: mockAuthApi.getSession } }; @@ -445,142 +437,98 @@ describe("OnboardingWizard restore-gate (stale localStorage across accounts)", ( vi.clearAllMocks(); }); - describe("qualified Copilot onboarding", () => { - async function openCopilot(overrides: Record = {}) { - mockInstanceSettingsApi.getExperimental.mockResolvedValue({ enableManagedSandboxOnly: false, enableNativeRunner: true }); - mockCompany.companies = [{ id: "c1", name: "Canary", issuePrefix: "CAN" }]; + describe("first-agent provider choices", () => { + async function openProviderChoices(savedOverrides: Record = {}) { + mockCompany.companies = [{ id: "c1", name: "Acme", issuePrefix: "ACM" }]; mockCompaniesApi.list.mockResolvedValue(mockCompany.companies); - mockAdapterRegistry.list = [{ type: "claude_local" }, { type: "paperclip_runner" }]; - window.localStorage.setItem(ONBOARDING_STORAGE_KEY, JSON.stringify({ step: 4, createdCompanyId: "c1", agentName: "Copilot canary", ...overrides })); - mockAgentsApi.adapterModels.mockResolvedValue([{ id: "gpt-5.6-luna", label: "GPT 5.6 Luna" }]); - mockAdapterBuild.buildAdapterConfig.mockImplementation((...args: unknown[]) => { - const values = args[0] as { adapterSchemaValues: Record; model: string }; - if (!values.model) throw new Error("Copilot requires a selected model"); - return { ...values.adapterSchemaValues, model: values.model }; - }); + mockAdapterRegistry.list = [ + { type: "claude_local" }, { type: "codex_local" }, + { type: "paperclip_runner" }, { type: "copilot_runtime", recommended: true }, + { type: "gemini_local", recommended: true }, { type: "opencode_local" }, + ]; + window.localStorage.setItem(ONBOARDING_STORAGE_KEY, JSON.stringify({ + step: 4, createdCompanyId: "c1", agentName: "Ada", ...savedOverrides, + })); const { root, queryClient } = render(); - queryClient.setQueryData(queryKeys.instance.experimentalSettings, { enableManagedSandboxOnly: false, enableNativeRunner: true }); - await act(async () => root.render()); + await renderWizardView(root, queryClient); await flushReact(); - const click = async (label: string) => { - const button = [...document.body.querySelectorAll("button")].find((entry) => entry.textContent?.includes(label))!; - expect(button).toBeTruthy(); - await act(async () => button.dispatchEvent(new MouseEvent("click", { bubbles: true }))); - await flushReact(); - }; - await click("GitHub Copilot"); - await flushReact(); - return { root, click }; + return root; } - it("keeps Copilot selection available while its setup has no model", async () => { - const { root, click } = await openCopilot(); + it.each([true, false, undefined].flatMap(enableNativeRunner => + (["paperclip", "legacy"] as const).map(defaultRunner => ({ enableNativeRunner, defaultRunner })), + ))("offers the same Claude and Codex choices with $defaultRunner availability and native runner setting $enableNativeRunner", async ({ enableNativeRunner, defaultRunner }) => { + mockInstanceSettingsApi.getExperimental.mockResolvedValue({ enableManagedSandboxOnly: false, enableNativeRunner }); + mockAdaptersApi.list.mockResolvedValue(["claude_local", "codex_local"].map(type => + setupAdapterInfo({ type, defaultRunner, supportedRunners: defaultRunner === "paperclip" ? ["paperclip", "legacy"] : ["legacy"] }), + )); + const root = await openProviderChoices(); try { - expect(mockAgentsApi.create).toHaveBeenCalled(); - expect(mockAgentsApi.update).toHaveBeenCalledWith(expect.any(String), expect.objectContaining({ adapterType: "paperclip_runner", adapterConfig: { provider: "acpx", acpxAgent: "copilot" }, metadata: { setupAdapterPending: false } }), "c1"); - await click("Use saved Copilot connection"); - await vi.waitFor(() => expect(document.body.querySelector('[aria-label="Copilot model"]')?.textContent).toContain("GPT 5.6 Luna")); - const model = document.body.querySelector('[aria-label="Copilot model"]') as HTMLSelectElement; - await act(async () => { model.value = "gpt-5.6-luna"; model.dispatchEvent(new Event("change", { bubbles: true })); }); - await vi.waitFor(() => expect(mockAgentsApi.update).toHaveBeenCalledWith(expect.any(String), expect.objectContaining({ adapterConfig: expect.objectContaining({ model: "gpt-5.6-luna" }) }), "c1")); + const sources = [...document.body.querySelectorAll("button[aria-checked]")]; + expect(sources).toHaveLength(2); + expect(sources.map(source => source.textContent)).toEqual([ + expect.stringContaining("Claude"), expect.stringContaining("OpenAI"), + ]); + expect(document.body.textContent).not.toContain("GitHub Copilot"); + expect(document.body.textContent).not.toContain("Windows"); + expect(JSON.parse(window.localStorage.getItem(ONBOARDING_STORAGE_KEY)!).runner).toBe("auto"); } finally { await act(async () => root.unmount()); } }); - it("restores the saved Copilot source and model without switching providers", async () => { - const setupId = "00000000-0000-4000-8000-000000000085"; - mockAgentsApi.create.mockResolvedValueOnce({ id: setupId, companyId: "c1", name: "Copilot canary", role: "general", - adapterType: "paperclip_runner", adapterConfig: { provider: "acpx", acpxAgent: "copilot", model: "gpt-5.6-luna" }, - pauseReason: "setup", runtimeConfig: { aiConnection: { provider: "github", method: "api_key", mode: "responsible_user" } }, - } as never); - const { root, click } = await openCopilot({ adapterType: "paperclip_runner", onboardingCopilot: true, setupAgentId: setupId }); + it("still hides disabled providers from first-agent setup", async () => { + mockAdapterRegistry.disabled = new Set(["claude_local"]); + const root = await openProviderChoices(); try { - await vi.waitFor(() => expect((document.body.querySelector('[aria-label="Copilot model"]') as HTMLSelectElement)?.value).toBe("gpt-5.6-luna")); - expect(JSON.parse(window.localStorage.getItem(ONBOARDING_STORAGE_KEY)!)).toMatchObject({ adapterType: "paperclip_runner", model: "gpt-5.6-luna" }); - await click("Connect"); - expect(mockAgentsApi.hire).toHaveBeenCalledWith("c1", expect.objectContaining({ setupAgentId: setupId, adapterType: "paperclip_runner" })); + const sources = [...document.body.querySelectorAll("button[aria-checked]")]; + expect(sources).toHaveLength(1); + expect(sources[0]?.textContent).toContain("OpenAI"); } finally { await act(async () => root.unmount()); } }); - it.each(["choices", "authentication error"])("refreshes Copilot onboarding after reconnecting the same binding with cached %s", async (cachedState) => { - const { root, click } = await openCopilot(); + it("requires a new choice when resuming a Copilot draft", async () => { + const root = await openProviderChoices({ + adapterType: "paperclip_runner", onboardingCopilot: true, model: "copilot-model", + }); try { - mockAgentsApi.adapterModels.mockReset(); - if (cachedState === "authentication error") mockAgentsApi.adapterModels.mockRejectedValueOnce(new Error("Expired token")); - else mockAgentsApi.adapterModels.mockResolvedValueOnce([{ id: "old-model", label: "Old account model" }]); - await click("Use saved Copilot connection"); - await vi.waitFor(() => expect(mockAgentsApi.adapterModels).toHaveBeenCalledTimes(1)); - mockAgentsApi.adapterModels.mockResolvedValue([{ id: "new-model", label: "New account model" }]); - await click("Use saved Copilot connection"); - await vi.waitFor(() => expect(mockAgentsApi.adapterModels).toHaveBeenCalledTimes(2)); - await vi.waitFor(() => expect(document.body.querySelector('[aria-label="Copilot model"]')?.textContent).toContain("New account model")); - expect(document.body.textContent).not.toContain("Old account model"); - expect(document.body.textContent).not.toContain("Expired token"); - expect((document.body.querySelector('[aria-label="Copilot model"]') as HTMLSelectElement).value).toBe(""); - } finally { await act(async () => root.unmount()); } - }); - - it.each([null, "copilot-daytona"])("discovers models and tests the same saved-token configuration it hires in environment %s", async (environmentId) => { - mockInstanceSettingsApi.get.mockResolvedValue({ defaultEnvironmentId: environmentId }); - mockEnvironmentsApi.list.mockResolvedValue(environmentId ? [{ id: environmentId, name: "Daytona", driver: "sandbox", status: "active", config: { provider: "daytona" } }] : []); - const { root, click } = await openCopilot(); - const claudeReadsBeforeCopilot = mockAgentsApi.getClaudeOAuthTokenStatus.mock.calls.length; - expect((mockAgentsApi.adapterModels.mock.calls as unknown[][]).some((call) => call[1] === "paperclip_runner")).toBe(false); - expect(mockAgentsApi.hire).not.toHaveBeenCalled(); - await click("Use saved Copilot connection"); - expect(document.body.querySelector('[data-environment]')?.getAttribute("data-environment") ?? null).toBe(environmentId); - expect(mockAgentsApi.adapterModels).toHaveBeenCalledWith("c1", "paperclip_runner", { environmentId, provider: "acpx", acpxAgent: "copilot", aiConnection: { provider: "github", method: "api_key", mode: "responsible_user" } }); - const select = document.body.querySelector('[aria-label="Copilot model"]') as HTMLSelectElement; - expect(select.value).toBe(""); - await act(async () => { select.value = "gpt-5.6-luna"; select.dispatchEvent(new Event("change", { bubbles: true })); }); - await flushReact(); - await click("Connect"); - const tested = mockAgentsApi.testEnvironment.mock.calls[0] as unknown as [string, string, { adapterConfig: Record; aiConnection: unknown }]; - const hired = mockAgentsApi.hire.mock.calls[0] as unknown[]; - expect(tested).toMatchObject(["c1", "paperclip_runner", { adapterConfig: { provider: "acpx", acpxAgent: "copilot", model: "gpt-5.6-luna" }, aiConnection: { provider: "github", method: "api_key", mode: "responsible_user" } }]); - expect(hired).toMatchObject(["c1", { adapterType: "paperclip_runner", adapterConfig: tested[2].adapterConfig, runtimeConfig: { aiConnection: tested[2].aiConnection } }]); - expect(mockAgentsApi.getClaudeOAuthTokenStatus.mock.calls.length).toBe(claudeReadsBeforeCopilot); - expect(JSON.stringify(hired)).not.toContain("COPILOT_GITHUB_TOKEN"); - 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()); - mockInstanceSettingsApi.get.mockResolvedValue({ defaultEnvironmentId: null }); - mockEnvironmentsApi.list.mockResolvedValue([]); - }); - - it("reuses an existing Copilot hire after losing its draft id without adopting another Runner", async () => { - mockAgentsApi.list.mockResolvedValue([ - { id: "codex-other", name: "Copilot canary", adapterType: "paperclip_runner", adapterConfig: { provider: "codex" } }, - { id: "copilot-existing", name: "Copilot canary", adapterType: "paperclip_runner", adapterConfig: { provider: "acpx", acpxAgent: "copilot", model: "gpt-5.6-luna" } }, - ]); - const { root, click } = await openCopilot(); - try { - await click("Use saved Copilot connection"); - const select = document.body.querySelector('[aria-label="Copilot model"]') as HTMLSelectElement; - await act(async () => { select.value = "gpt-5.6-luna"; select.dispatchEvent(new Event("change", { bubbles: true })); }); - await flushReact(); - await click("Connect"); + expect(document.body.querySelector('button[aria-checked="true"]')).toBeNull(); + expect(document.body.textContent).not.toContain("Connect GitHub Copilot"); + const connect = [...document.body.querySelectorAll("button")].find(button => isArcPrimary(button.textContent?.trim() ?? "")); + expect(connect?.disabled).toBe(true); expect(mockAgentsApi.hire).not.toHaveBeenCalled(); - expect(JSON.parse(window.localStorage.getItem(ONBOARDING_STORAGE_KEY)!)).toMatchObject({ createdAgentId: "copilot-existing" }); - expect(document.body.textContent).toContain("Copilot canary is ready to work!"); - } finally { - await act(async () => root.unmount()); - } + expect(JSON.parse(window.localStorage.getItem(ONBOARDING_STORAGE_KEY)!)).toMatchObject({ + adapterType: "claude_local", runner: "auto", model: "", + }); + } finally { await act(async () => root.unmount()); } }); - it("refuses an unavailable model after refresh without substituting or hiring", async () => { - const { root, click } = await openCopilot(); - await click("Use saved Copilot connection"); - const select = document.body.querySelector('[aria-label="Copilot model"]') as HTMLSelectElement; - await act(async () => { select.value = "gpt-5.6-luna"; select.dispatchEvent(new Event("change", { bubbles: true })); }); - await flushReact(); - mockAgentsApi.adapterModels.mockResolvedValue([{ id: "other-model", label: "Other model" }]); - await click("Refresh models"); - expect(select.value).toBe("gpt-5.6-luna"); - expect(select.textContent).toContain("gpt-5.6-luna (unavailable)"); - const primary = [...document.body.querySelectorAll("button")].find((entry) => entry.textContent?.trim() === "Connect"); - expect(primary?.disabled).toBe(true); - expect(mockAgentsApi.testEnvironment).not.toHaveBeenCalled(); - expect(mockAgentsApi.hire).not.toHaveBeenCalled(); - await act(async () => root.unmount()); + it("clears a saved Gemini model when only Codex is available", async () => { + mockAdapterRegistry.disabled = new Set(["claude_local"]); + const root = await openProviderChoices({ adapterType: "gemini_local", model: "gemini-model" }); + try { + expect(document.body.querySelector('button[aria-checked="true"]')).toBeNull(); + expect(JSON.parse(window.localStorage.getItem(ONBOARDING_STORAGE_KEY)!)).toMatchObject({ + adapterType: "codex_local", model: "", + }); + expect(mockAgentsApi.hire).not.toHaveBeenCalled(); + } finally { await act(async () => root.unmount()); } + }); + + it.each([false, true])("explains unavailable onboarding providers and clears an old Copilot draft: %s", async savedCopilot => { + mockAdapterRegistry.disabled = new Set(["claude_local", "codex_local"]); + const root = await openProviderChoices(savedCopilot ? { + adapterType: "paperclip_runner", onboardingCopilot: true, runner: "paperclip", model: "copilot-model", + } : {}); + try { + expect(document.body.querySelectorAll("button[aria-checked]")).toHaveLength(0); + expect(document.body.textContent).toContain("Claude and Codex are unavailable on this instance."); + expect(document.body.textContent).toContain("choose New Agent on the Agents page"); + const next = [...document.body.querySelectorAll("button")].find(button => isArcPrimary(button.textContent?.trim() ?? "")); + expect(next?.disabled).toBe(true); + expect(mockAgentsApi.hire).not.toHaveBeenCalled(); + const saved = JSON.parse(window.localStorage.getItem(ONBOARDING_STORAGE_KEY)!); + expect(saved).toMatchObject({ adapterType: "claude_local", runner: "auto", model: "" }); + expect(saved).not.toHaveProperty("onboardingCopilot"); + } finally { await act(async () => root.unmount()); } }); }); @@ -2514,15 +2462,6 @@ describe("OnboardingWizard restore-gate (stale localStorage across accounts)", ( }); it("will not advance on a saved adapter the row does not show", async () => { - // The other shape of the same defect, and the one the snap cannot cover. - // - // The tile row is `recommendedAdapters`; the snap's idea of "visible" is - // recommended *plus* the advanced list. An adapter in the second but not - // the first — a saved `opencode_local`, say — therefore satisfies the - // snap, which leaves it alone, while the row it is supposed to be chosen - // in never shows it. Nothing is highlighted, the canvas is shut, and with - // the gate on `sourcePicked` the CTA was live: one press hires against an - // adapter the customer has not seen on this screen. mockAdapterRegistry.list = [setupAdapterInfo({ type: "claude_local" }), { type: "opencode_local" }]; const { root } = await openStep4({ adapterType: "opencode_local" }); @@ -2733,6 +2672,34 @@ describe("OnboardingWizard restore-gate (stale localStorage across accounts)", ( await act(async () => root.unmount()); }); + it.each([false, true])("migrates a stored Copilot setup without reusing its model or binding, with providers disabled: %s", async providersDisabled => { + const setupId = "00000000-0000-4000-8000-000000000095"; + if (providersDisabled) mockAdapterRegistry.disabled = new Set(["claude_local", "codex_local"]); + mockAgentsApi.create.mockResolvedValueOnce({ id: setupId, companyId: "company-new", name: "Ada", role: "general", + adapterType: "paperclip_runner", adapterConfig: { provider: "acpx", acpxAgent: "copilot", model: "copilot-model" }, + runtimeConfig: { aiConnection: { provider: "github", method: "api_key", mode: "responsible_user" } }, + pauseReason: "setup", defaultEnvironmentId: "env-sandbox-1" } as never); + const { root } = await openStep4({ adapterType: "paperclip_runner", onboardingCopilot: true, setupAgentId: setupId }); + try { + const saved = JSON.parse(window.localStorage.getItem(ONBOARDING_STORAGE_KEY)!); + expect(saved).toMatchObject({ adapterType: "claude_local", runner: "auto", model: "", setupAgentId: setupId }); + expect(saved).not.toHaveProperty("onboardingCopilot"); + expect(document.body.querySelector('button[aria-checked="true"]')).toBeNull(); + expect(mockAgentsApi.hire).not.toHaveBeenCalled(); + if (!providersDisabled) { + await pickSource(/Claude/); + const saves = mockAgentsApi.update.mock.calls.filter(([id, body]) => id === setupId && body.adapterConfig); + expect(saves.length).toBeGreaterThan(0); + for (const [, body] of saves) { + expect(body).toMatchObject({ adapterType: "claude_local", runner: "auto" }); + expect(body.adapterConfig).not.toHaveProperty("acpxAgent", "copilot"); + expect(body.adapterConfig).not.toHaveProperty("model", "copilot-model"); + expect(body.runtimeConfig).not.toHaveProperty("aiConnection.provider", "github"); + } + } + } finally { await act(async () => root.unmount()); } + }); + it("tests and hires in the restored setup environment instead of the instance default", async () => { const setupId = "00000000-0000-4000-8000-000000000083"; mockEnvironmentsApi.list.mockResolvedValue([SANDBOX_ENVIRONMENT, { ...SANDBOX_ENVIRONMENT, id: "saved-environment" }]); @@ -3874,31 +3841,41 @@ describe("OnboardingWizard restore-gate (stale localStorage across accounts)", ( expect(document.body.textContent).not.toContain("Start sign-in again"); } - it.each(["paperclip", "legacy"] as const)("waits for Claude runner discovery before saved subscription verification (%s)", async defaultRunner => { + it.each(providers.flatMap(([adapterType, provider, source]) => + (["paperclip", "legacy"] as const).map(defaultRunner => ({ adapterType, provider, source, defaultRunner })), + ))("waits for $adapterType discovery before verification and keeps $defaultRunner routing automatic", async ({ adapterType, provider, source, defaultRunner }) => { let finish!: (value: import("../api/adapters").AdapterInfo[]) => void; mockAdaptersApi.list.mockReturnValue(new Promise(resolve => { finish = resolve; })); - mockAdapterBuild.buildAdapterConfig.mockImplementation(values => buildClaudeLocalConfig(values!)); - const { root, queryClient } = await openStep4({ adapterType: "claude_local" }); + mockAdapterBuild.buildAdapterConfig.mockImplementation(values => adapterType === "claude_local" + ? buildClaudeLocalConfig(values!) : buildCodexRunnerConfig(values!)); + const { root, queryClient } = await openStep4({ adapterType }); try { await act(async () => queryClient.setQueryData(["ai-connections", "company-new"], { - currentUserId: "user-1", connections: [{ id: "saved", grantId: "grant", companyId: "company-new", provider: "anthropic", method: "subscription", ownership: "personal", ownerUserId: "user-1", status: "connected", isDefault: true }], + currentUserId: "user-1", connections: [{ id: "saved", grantId: "grant", companyId: "company-new", provider, method: "subscription", ownership: "personal", ownerUserId: "user-1", status: "connected", isDefault: true }], })); - await pickSource(/Claude/); + await pickSource(source); await settle(); expect(button("Connect").disabled).toBe(true); await act(async () => button("Connect").dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", metaKey: true, bubbles: true }))); await settle(); expect(mockAgentsApi.testEnvironment).not.toHaveBeenCalled(); expect(mockAgentsApi.hire).not.toHaveBeenCalled(); - await act(async () => finish([setupAdapterInfo({ type: "claude_local", defaultRunner, supportedRunners: ["paperclip", "legacy"] })])); + await act(async () => finish([setupAdapterInfo({ type: adapterType, defaultRunner, supportedRunners: defaultRunner === "paperclip" ? ["paperclip", "legacy"] : ["legacy"] })])); await settle(); expect(mockAgentsApi.testEnvironment).toHaveBeenCalledTimes(1); expect(mockAgentsApi.hire).toHaveBeenCalledTimes(1); const tested = mockAgentsApi.testEnvironment.mock.calls[0] as unknown as [string, string, { adapterConfig: Record }]; const hired = mockAgentsApi.hire.mock.calls[0] as unknown as [string, { adapterConfig: Record }]; + expect(mockAdapterBuild.buildAdapterConfig).toHaveBeenCalledWith(expect.objectContaining({ adapterType, runner: "auto", resolvedRunner: defaultRunner })); + expect(tested[1]).toBe(adapterType); + expect(tested[2]).not.toHaveProperty("runner"); + expect(hired[1]).toMatchObject({ adapterType, onboardingFirstAgent: true }); + expect(hired[1]).not.toHaveProperty("runner"); expect(hired[1].adapterConfig).toEqual(tested[2].adapterConfig); - if (defaultRunner === "paperclip") expect(hired[1].adapterConfig).not.toHaveProperty("maxTurnsPerRun"); - else expect(hired[1].adapterConfig.maxTurnsPerRun).toBe(1000); + if (adapterType === "claude_local") { + if (defaultRunner === "paperclip") expect(hired[1].adapterConfig).not.toHaveProperty("maxTurnsPerRun"); + else expect(hired[1].adapterConfig.maxTurnsPerRun).toBe(1000); + } expect(hired[1].adapterConfig).not.toHaveProperty("resolvedRunner"); } finally { await act(async () => root.unmount()); } }); @@ -4241,14 +4218,17 @@ describe("OnboardingWizard restore-gate (stale localStorage across accounts)", ( }); }); - it("keeps Gemini local login out of managed Claude subscription setup", async () => { + it("does not offer Gemini from a saved initial onboarding draft", async () => { localHealth.get.mockResolvedValue({ deploymentMode: "local_trusted" }); mockEnvironmentsApi.list.mockResolvedValue([LOCAL_ENVIRONMENT]); mockInstanceSettingsApi.get.mockResolvedValue({ defaultEnvironmentId: null }); mockAdapterRegistry.list.push({ type: "gemini_local", recommended: true }); const { root } = await openStep4({ adapterType: "gemini_local" }); try { - await pickSource(/Gemini|Google|gemini_local/); + const sources = [...document.body.querySelectorAll("button[aria-checked]")]; + expect(sources).toHaveLength(2); + expect(sources.some(source => /Gemini|Google|gemini_local/.test(source.textContent ?? ""))).toBe(false); + expect(document.body.querySelector('button[aria-checked="true"]')).toBeNull(); expect(managedApi.startLocalLogin).not.toHaveBeenCalled(); expect(managedApi.connectLocal).not.toHaveBeenCalled(); expect(document.body.textContent).not.toContain("claude auth login"); diff --git a/ui/src/components/OnboardingWizard.tsx b/ui/src/components/OnboardingWizard.tsx index f8f806b0f72..1018ca40fb4 100644 --- a/ui/src/components/OnboardingWizard.tsx +++ b/ui/src/components/OnboardingWizard.tsx @@ -1,4 +1,3 @@ -import { COPILOT_MODEL_DISCOVERY_QUERY_OPTIONS } from "@/lib/copilot-task-model-catalog"; import type { AgentRunner, AgentRunnerChoice } from "@paperclipai/shared"; import { needsClaudeSetupQualification, qualifyClaudeSetup } from "../lib/qualify-claude-setup"; import { agentHarnessType } from "@paperclipai/shared"; @@ -8,10 +7,8 @@ import { healthApi } from "@/api/health"; import { LocalProviderLoginInstructions } from "./AdapterLoginChrome"; import { useLocalAiLogin } from "./ai-connections/useLocalAiLogin"; import { aiConnectionsApi } from "@/api/ai-connections"; -import { AiConnectionField, aiProviderForAdapter } from "./ai-connections/AiConnectionField"; +import { aiProviderForAdapter } from "./ai-connections/AiConnectionField"; import { useConnectionModelsState } from "./ai-connections/useConnectionModels"; -import { PAPERCLIP_RUNNER_ACPX_PROFILES } from "@paperclipai/adapter-utils"; -import { aiConnectionBindingSchema } from "@paperclipai/shared"; import type { AiConnectionBinding } from "@paperclipai/shared"; import { storeProviderApiKey } from "../lib/provider-credential"; import { SavedProviderKeySelect, useSavedProviderKeys } from "./onboarding/SavedProviderKeySelect"; @@ -158,23 +155,13 @@ type Step = 0 | 1 | 2 | 3 | 4 | 5; // wizard's registry-driven approach rather than a fixed union. type AdapterType = string; -// Only the qualified Copilot profile is offered through Runner in onboarding. -// Other Runner profiles remain explicit agent configuration choices. -const ONBOARDING_EXCLUDED_ADAPTER_TYPES = new Set([ - "process", - "http", - "paperclip_runner", -]); - -const copilotOnboardingQualified = PAPERCLIP_RUNNER_ACPX_PROFILES.some( - (profile) => profile.value === "copilot" && profile.qualified, -); +const ONBOARDING_ADAPTER_TYPES = new Set(["claude_local", "codex_local"]); function restoreOnboardingAdapterType(savedAdapterType: unknown, provider?: unknown, savedCopilot?: unknown): AdapterType { if (savedAdapterType === "paperclip_runner" && provider === "pi") return "pi_local"; if (savedAdapterType === "paperclip_runner" && provider === "opencode") return "opencode_local"; if (savedAdapterType === "paperclip_runner" && savedCopilot !== true && (provider === undefined || provider === "codex")) return "codex_local"; - return typeof savedAdapterType === "string" && (savedAdapterType !== "paperclip_runner" || (savedCopilot === true && copilotOnboardingQualified)) + return typeof savedAdapterType === "string" && savedAdapterType !== "paperclip_runner" ? savedAdapterType : "claude_local"; } @@ -268,7 +255,6 @@ const MODEL_SOURCE_INLINE_MARKS: Record = { claude_local: ANTHROPIC_API_KEY_ENV_KEY, codex_local: "OPENAI_API_KEY", - paperclip_runner: "COPILOT_GITHUB_TOKEN", }; function apiKeyEnvKeyFor(adapterType: string): string { @@ -611,10 +597,11 @@ function OnboardingWizardInner({ // one. (saved?.agentRole as AgentRole) || DEFAULT_AGENT_ROLE, ); + const savedCopilotDraft = saved?.adapterType === "paperclip_runner" && saved?.onboardingCopilot === true; const [adapterType, setAdapterType] = useState(() => restoreOnboardingAdapterType(saved?.adapterType, saved?.runnerProvider, saved?.onboardingCopilot), ); - const [runner, setRunner] = useState(() => saved?.runner === "legacy" || saved?.runner === "paperclip" + const [runner, setRunner] = useState(() => savedCopilotDraft ? "auto" : saved?.runner === "legacy" || saved?.runner === "paperclip" ? saved.runner : saved?.adapterType === "paperclip_runner" && (saved?.runnerProvider === undefined || ["codex", "claude", "acpx", "opencode", "pi"].includes(String(saved?.runnerProvider))) ? "paperclip" : "auto"); /** * Whether a model source has been chosen, as opposed to which one @@ -640,15 +627,13 @@ function OnboardingWizardInner({ */ const [sourcePicked, setSourcePicked] = useState(false); const [setupNameConflict, setSetupNameConflict] = useState(null); - const isCopilot = adapterType === "paperclip_runner"; - const [copilotConnection, setCopilotConnection] = useState(); const [gatewayConnection, setGatewayConnection] = useState<{ companyId: string; adapterType: string; binding: AiConnectionBinding; }>(); - const savedNativeRunnerDraft = saved?.adapterType === "paperclip_runner" && !(saved?.onboardingCopilot === true && copilotOnboardingQualified); + const savedNativeRunnerDraft = saved?.adapterType === "paperclip_runner" && !savedCopilotDraft; const [cwd, setCwd] = useState((saved?.cwd as string) ?? ""); const [model, setModel] = useState( - savedNativeRunnerDraft && saved?.runnerProvider !== undefined && !["codex", "claude", "acpx", "opencode", "pi"].includes(String(saved.runnerProvider)) ? "" : (saved?.model as string) ?? "", + savedCopilotDraft || (savedNativeRunnerDraft && saved?.runnerProvider !== undefined && !["codex", "claude", "acpx", "opencode", "pi"].includes(String(saved.runnerProvider))) ? "" : (saved?.model as string) ?? "", ); const [command, setCommand] = useState( saved?.adapterType === "paperclip_runner" ? "" : (saved?.command as string) ?? "", @@ -731,7 +716,7 @@ function OnboardingWizardInner({ ? selectedSavedKey.id : savedKeys.options[0]?.id; const selectedApiKey = savedKeys.options.find((option) => option.id === selectedApiKeyId); - const credentialMode = isCopilot ? "api" : credentialModeChoice ?? ( + const credentialMode = credentialModeChoice ?? ( (savedKeys.subscriptions.length > 0 || (adapterType === "claude_local" && savedKeys.storedLogin.data)) ? "subscription" : savedKeys.options.length || adapterType === "opencode_local" ? "api" : "subscription" ); @@ -791,10 +776,9 @@ function OnboardingWizardInner({ */ const apiKeySecretRef = useRef<{ key: string; companyId: string; envKey: string; binding?: Awaited>["binding"]; aiConnection?: AiConnectionBinding } | null>(null); const managedSubscriptionRef = useRef<{ companyId: string; binding: AiConnectionBinding } | null>(null); - const managedProvider = aiProviderForAdapter(isCopilot ? "copilot_runtime" : adapterType); + const managedProvider = aiProviderForAdapter(adapterType); const managedSubscriptionProvider = managedProvider === "anthropic" || managedProvider === "openai" || managedProvider === "xai" ? managedProvider : undefined; function managedBindingForStep(): AiConnectionBinding | undefined { - if (isCopilot) return copilotConnection; if (credentialMode === "api" && gatewayConnection?.companyId === createdCompanyId && gatewayConnection.adapterType === adapterType) return gatewayConnection.binding; const restoredBinding = restoredSetup?.companyId === createdCompanyId && agentHarnessType(restoredSetup.adapterType, restoredSetup.adapterConfig) === agentHarnessType(adapterType, {}) @@ -850,7 +834,6 @@ function OnboardingWizardInner({ */ function clearCompanyScopedState() { setRestoredSetup(null); - setCopilotConnection(undefined); setGatewayConnection(undefined); setAgentAppearance(randomAgentAppearance()); setCreatedCompanyPrefix(null); @@ -969,7 +952,6 @@ function OnboardingWizardInner({ const state = { step, companyName, agentName, agentAppearance, agentRole, adapterType, runner, cwd, model, command, args, url, - onboardingCopilot: isCopilot, // The mode, never the key: this blob is localStorage. credentialMode, credentialModeChoice, createdCompanyId, createdCompanyPrefix, createdAgentId, setupAgentId, @@ -1013,7 +995,6 @@ function OnboardingWizardInner({ queryFn: () => instanceSettingsApi.getExperimental(), enabled: effectiveOnboardingOpen && step >= 3 && step <= 5, }); - const copilotOnboardingEnabled = copilotOnboardingQualified; const loginEnvironmentResolution = useMemo(() => { try { return { environmentId: resolveAdapterTestEnvironmentId({ @@ -1042,31 +1023,21 @@ function OnboardingWizardInner({ enabled: Boolean(createdCompanyId) && effectiveOnboardingOpen && step === 4 && loginEnvironmentSettingsReady, }); - const copilotEnvironmentReady = loginEnvironmentsReady && loginSettingsReady && loginPolicyReady && !loginEnvironmentResolution.error; const { data: adapterModels, error: adapterModelsError, isLoading: adapterModelsLoading, isFetching: adapterModelsFetching, - refetch: refreshAdapterModels, } = useQuery({ - queryKey: isCopilot - ? ["onboarding-adapter-models", createdCompanyId, adapterType, resolvedLoginEnvironmentId, copilotConnection] - : createdCompanyId ? queryKeys.agents.adapterModels(createdCompanyId, adapterType, null) : ["agents", "none", "adapter-models", adapterType, null], - queryFn: () => agentsApi.adapterModels(createdCompanyId!, adapterType, { - environmentId: isCopilot ? resolvedLoginEnvironmentId : null, - ...(isCopilot ? { provider: "acpx", acpxAgent: "copilot", aiConnection: copilotConnection } : {}), - }), - ...(isCopilot ? COPILOT_MODEL_DISCOVERY_QUERY_OPTIONS : {}), - enabled: Boolean(createdCompanyId) && effectiveOnboardingOpen && step === 4 && (!isCopilot || Boolean(copilotConnection && copilotEnvironmentReady)), + queryKey: createdCompanyId ? queryKeys.agents.adapterModels(createdCompanyId, adapterType, null) : ["agents", "none", "adapter-models", adapterType, null], + queryFn: () => agentsApi.adapterModels(createdCompanyId!, adapterType, { environmentId: null }), + enabled: Boolean(createdCompanyId) && effectiveOnboardingOpen && step === 4 && ONBOARDING_ADAPTER_TYPES.has(adapterType), retry: false, }); - const requestCopilotModels = Boolean(isCopilot && createdCompanyId && effectiveOnboardingOpen && step === 4 && copilotConnection && copilotEnvironmentReady); - const copilotModelScope = JSON.stringify([createdCompanyId, resolvedLoginEnvironmentId, copilotConnection]); - useEffect(() => { - if (requestCopilotModels) void refreshAdapterModels(); - }, [requestCopilotModels, copilotModelScope, refreshAdapterModels]); - const copilotModelReady = Boolean(copilotConnection && copilotEnvironmentReady && !adapterModelsError && !adapterModelsFetching && adapterModels?.some((entry) => entry.id === model.trim())); + // Automatic selection uses native Claude/Codex on qualified execution targets and + // silently falls back to legacy on Windows, keeping the same onboarding UI. + // When Windows native execution is qualified, enable it in + // paperclipRunnerSupportsPlatform so Windows onboarding defaults to native too. const needsRunnerDiscovery = ["codex_local", "claude_local", "opencode_local", "pi_local"].includes(adapterType) && runner === "auto"; const resolvedRunner = runner === "auto" ? runnerDiscovery.data?.find(item => item.type === adapterType)?.defaultRunner @@ -1253,7 +1224,7 @@ function OnboardingWizardInner({ adapterCaps.supportsSkills || adapterCaps.supportsLocalAgentJwt; const isLocalAdapter = - isLocalAdapterCaps || isCopilot || + isLocalAdapterCaps || adapterType === "claude_local" || adapterType === "codex_local" || adapterType === "gemini_local" || @@ -1267,20 +1238,17 @@ function OnboardingWizardInner({ const { recommendedAdapters, moreAdapters } = useMemo(() => { const all = listUIAdapters() .filter((a) => - !ONBOARDING_EXCLUDED_ADAPTER_TYPES.has(a.type) && + ONBOARDING_ADAPTER_TYPES.has(a.type) && !disabledTypes.has(a.type) && isVisualAdapterChoice(a.type) ) .map((a) => ({ ...getAdapterDisplay(a.type), type: a.type })); - 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], + recommendedAdapters: all.filter((a) => a.recommended), moreAdapters: all.filter((a) => !a.recommended), }; - }, [disabledTypes, copilotOnboardingEnabled]); + }, [disabledTypes]); /** * A source chosen from the visible row. Read off the row rather than off @@ -1308,9 +1276,9 @@ function OnboardingWizardInner({ * once rather than twice. */ const restoredApiCredential = restoredSetup?.companyId === createdCompanyId - && agentHarnessType(restoredSetup.adapterType, restoredSetup.adapterConfig) === agentHarnessType(adapterType, isCopilot ? { provider: "acpx", acpxAgent: "copilot" } : {}) + && agentHarnessType(restoredSetup.adapterType, restoredSetup.adapterConfig) === agentHarnessType(adapterType) && !selectedSavedKey && (restoredSetup.adapterConfig.env as Record | undefined)?.[apiKeyEnvKeyFor(adapterType)]; - const connectStepReady = Boolean(setupPrepared || createdAgentId) && sourceSelected && !adapterEnvLoading && !savedKeys.loading && runnerDiscoveryReady && (!isCopilot || copilotModelReady) && connectionModelReady; + const connectStepReady = Boolean(setupPrepared || createdAgentId) && sourceSelected && !adapterEnvLoading && !savedKeys.loading && runnerDiscoveryReady && connectionModelReady; useEffect(() => { if (step !== 4 || !createdCompanyId || !agentName.trim() || !adapterRegistryLoaded @@ -1327,7 +1295,7 @@ function OnboardingWizardInner({ agent.status !== "terminated" && agent.pauseReason !== "setup" && agent.name.trim().toLowerCase() === agentName.trim().toLowerCase()); const existing = sourceSelected ? sameName.find(agent => agentHarnessType(agent.adapterType, agent.adapterConfig) - === agentHarnessType(adapterType, isCopilot ? { provider: "acpx", acpxAgent: "copilot" } : {})) : undefined; + === agentHarnessType(adapterType)) : undefined; if (existing) { restoring = true; return existing; } // A same-name record may already be complete; choose its provider before adopting it. if (!sourceSelected && sameName.length) { @@ -1347,7 +1315,7 @@ function OnboardingWizardInner({ // Apply the runner only once a real source is chosen; sending a native // runner with the provisional process adapter is an invalid combination. ...(sourceSelected ? { runner } : {}), - adapterConfig: createAdapter === "paperclip_runner" ? { provider: "acpx", acpxAgent: "copilot" } : {}, + adapterConfig: {}, metadata: { setupAdapterPending: !sourceSelected }, onboardingFirstAgent: true, runtimeConfig: buildNewAgentRuntimeConfig({ heartbeatEnabled: false }), }); @@ -1369,18 +1337,18 @@ function OnboardingWizardInner({ setArgs(""); setUrl(""); } else { - setAdapterType(agentHarnessType(agent.adapterType, config) === "acpx:copilot" ? "paperclip_runner" : agentHarnessType(agent.adapterType, config) as AdapterType); + setAdapterType(agentHarnessType(agent.adapterType, config)); setModel(typeof config.model === "string" ? config.model : ""); setCommand(typeof config.command === "string" ? config.command : ""); setArgs(Array.isArray(config.args) ? config.args.join(" ") : ""); setUrl(typeof config.url === "string" ? config.url : ""); - setRunner(agent.adapterType === "paperclip_runner" ? "paperclip" : "legacy"); + setRunner(ONBOARDING_ADAPTER_TYPES.has(agentHarnessType(agent.adapterType, config)) + ? agent.adapterType === "paperclip_runner" ? "paperclip" : "legacy" : "auto"); const binding = agent.runtimeConfig?.aiConnection as AiConnectionBinding | undefined; if (binding) setCredentialMode(binding.method === "api_key" ? "api" : "subscription"); else if ((config.env as Record | undefined)?.[apiKeyEnvKeyFor(adapterType)]) setCredentialMode("api"); else if ((config.env as Record | undefined)?.[CLAUDE_OAUTH_TOKEN_ENV_KEY] || (config.env as Record | undefined)?.CODEX_HOME) setCredentialMode("subscription"); - if (config.provider === "acpx" && config.acpxAgent === "copilot") setCopilotConnection(binding); } } } else setCreatedAgentId(agent.id); @@ -1586,7 +1554,7 @@ function OnboardingWizardInner({ * rather than offering — see `FooterNav`, where the label cross-fades over an * easing width so those changes read as one control rather than four. */ - const connectSourceLabel = isCopilot ? "GitHub Copilot" : CONNECT_SOURCE_NAMES[adapterType] ?? adapterType; + const connectSourceLabel = CONNECT_SOURCE_NAMES[adapterType] ?? adapterType; const connectCta: { label: string; icon: FooterPrimaryIcon; disabled: boolean } = setupError ? { label: "Retry", icon: "arrow", disabled: false } : connectProgress ? { label: adapterEnvLoading ? "Testing…" : connectProgress, icon: "spinner", disabled: true } @@ -1605,7 +1573,7 @@ function OnboardingWizardInner({ label: "Connect", icon: "arrow", disabled: - !connectStepReady || (!isCopilot && credentialMode === "api" && !apiKey.trim() && !selectedApiKey && !restoredApiCredential && !managedBindingForStep()), + !connectStepReady || (credentialMode === "api" && !apiKey.trim() && !selectedApiKey && !restoredApiCredential && !managedBindingForStep()), } : // Nothing is chosen on arrival, and the row is what chooses. Until // it has been answered the button has nothing to do. @@ -1703,18 +1671,17 @@ 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 || (adapterType === "paperclip_runner" && !loginPolicyReady)) return; + if (!adapterRegistryLoaded) return; const visible = [...recommendedAdapters, ...moreAdapters].filter( (a) => !a.comingSoon, ); if (visible.length === 0) { - if (adapterType === "paperclip_runner" && !copilotOnboardingEnabled) { setAdapterType("claude_local"); setModel(""); setCopilotConnection(undefined); setSourcePicked(false); } + if (!ONBOARDING_ADAPTER_TYPES.has(adapterType)) { setAdapterType("claude_local"); setModel(""); 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; + const next = visible[0].type; 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 @@ -1722,7 +1689,6 @@ function OnboardingWizardInner({ // preselection this step was changed to stop doing. The saved answer was // unofferable, so the question is open again. setSourcePicked(false); - if (next === "codex_local") return; if (next === "opencode_local") { setModel(DEFAULT_OPENCODE_LOCAL_MODEL); return; @@ -1736,7 +1702,7 @@ function OnboardingWizardInner({ return; } setModel(""); - }, [adapterRegistryLoaded, recommendedAdapters, moreAdapters, adapterType, loginPolicyReady, copilotOnboardingEnabled]); + }, [adapterRegistryLoaded, recommendedAdapters, moreAdapters, adapterType]); const COMMAND_PLACEHOLDERS: Record = { claude_local: "claude", @@ -1766,7 +1732,7 @@ function OnboardingWizardInner({ setAdapterEnvResult(null); adapterEnvResultAppliedStoredLoginRef.current = false; if (adapterEnvErrorSelectionRef.current !== adapterSetupSelectionRef.current) setAdapterEnvError(null); - }, [step, adapterType, runner, model, command, args, url, credentialMode, apiKey, selectedSavedKey, selectedApiKey?.id, savedSubscription?.id, createdCompanyId, copilotConnection, gatewayConnection, resolvedLoginEnvironmentId, runner === "auto" ? runnerDiscovery.data?.find(item => item.type === adapterType)?.defaultRunner : undefined]); + }, [step, adapterType, runner, model, command, args, url, credentialMode, apiKey, selectedSavedKey, selectedApiKey?.id, savedSubscription?.id, createdCompanyId, gatewayConnection, resolvedLoginEnvironmentId, runner === "auto" ? runnerDiscovery.data?.find(item => item.type === adapterType)?.defaultRunner : undefined]); /** * Leaving the step puts the row back to a question. @@ -1865,7 +1831,6 @@ function OnboardingWizardInner({ setForceUnsetAnthropicApiKey(false); setUnsetAnthropicLoading(false); setClaudeOAuthStatus(null); - setCopilotConnection(undefined); setCreatedCompanyId(null); setCreatedCompanyPrefix(null); setCreatedAgentId(null); @@ -2049,13 +2014,13 @@ function OnboardingWizardInner({ function buildAdapterConfig(bindApiKey = false, setupRunner?: AgentRunner): Record { const adapter = getUIAdapter(adapterType); const savedConfig = restoredSetup?.companyId === createdCompanyId - && agentHarnessType(restoredSetup.adapterType, restoredSetup.adapterConfig) === agentHarnessType(adapterType, isCopilot ? { provider: "acpx", acpxAgent: "copilot" } : {}) + && agentHarnessType(restoredSetup.adapterType, restoredSetup.adapterConfig) === agentHarnessType(adapterType) ? restoredSetup.adapterConfig : {}; const builtConfig = adapter.buildAdapterConfig({ ...defaultCreateValues, adapterType, runner, - ...(isCopilot ? { adapterSchemaValues: { provider: "acpx", acpxAgent: "copilot" } } : adapterType === "pi_local" ? { adapterSchemaValues: savedConfig } : {}), + ...(adapterType === "pi_local" ? { adapterSchemaValues: savedConfig } : {}), resolvedRunner: setupRunner ?? resolvedRunner, model: adapterType === "gemini_local" @@ -2136,13 +2101,12 @@ function OnboardingWizardInner({ return { ...runtimeConfig, ...buildNewAgentRuntimeConfig({ heartbeatEnabled }), ...(binding ? { aiConnection: binding } : {}) }; } - const setupDetailsReady = runnerDiscoveryReady && (!isCopilot || copilotModelReady); const setupSnapshot = step === 4 && setupPrepared && setupAgentId && !createdAgentId && !loading ? JSON.stringify({ name: agentName.trim() || AGENT_ROLE_LABELS[agentRole], appearance: agentAppearance, - ...(sourceSelected && (setupDetailsReady || isCopilot) ? { + ...(sourceSelected && runnerDiscoveryReady ? { adapterType, runner, - adapterConfig: setupDetailsReady ? buildAdapterConfig(Boolean(apiKeySecretRef.current?.binding)) : { provider: "acpx", acpxAgent: "copilot" }, - ...(setupDetailsReady ? { defaultEnvironmentId: resolvedLoginEnvironmentId, runtimeConfig: buildSetupRuntimeConfig(false) } : {}), + adapterConfig: buildAdapterConfig(Boolean(apiKeySecretRef.current?.binding)), + defaultEnvironmentId: resolvedLoginEnvironmentId, runtimeConfig: buildSetupRuntimeConfig(false), metadata: { ...restoredSetup?.metadata, setupAdapterPending: false }, } : {}), }) @@ -2358,10 +2322,6 @@ function OnboardingWizardInner({ setError("Choose a model for this gateway connection before connecting your agent."); return; } - if (isCopilot && (!copilotOnboardingEnabled || !sourceSelected || !copilotModelReady)) { - setError("Connect GitHub Copilot and select an available model before creating this agent."); - return; - } if (createdAgentId) { setStep(5); return; @@ -2431,7 +2391,7 @@ function OnboardingWizardInner({ // hire describe it the same way — as a reference. A failure here stops the // hire rather than falling through to a configuration with no credential. let apiKeyStored = false; - if (!isCopilot && credentialMode === "api" && !selectedApiKey && apiKey.trim()) { + if (credentialMode === "api" && !selectedApiKey && apiKey.trim()) { apiKeyStored = await storeApiKeyUserSecret(createdCompanyId); if (!apiKeyStored || !isCurrent()) return; } @@ -3022,21 +2982,12 @@ function OnboardingWizardInner({ {step === 4 && (
- {/* Sources come from `recommendedAdapters`, not a list - written here — that filter is `recommended` in the - display registry, so a third tile appears the day - someone marks one rather than the day someone - remembers to edit this file. - - Picking one starts the sign-in now. The row is the - question, and answering it is what opens the card. */} ({ id: opt.type, - label: opt.type === "paperclip_runner" ? "GitHub Copilot" : CONNECT_SOURCE_NAMES[opt.type] ?? opt.label, + label: CONNECT_SOURCE_NAMES[opt.type] ?? opt.label, icon: , - ...(opt.type === "paperclip_runner" ? { credentialMode: "api" as const } : {}), }))} mode={credentialMode} selectedId={ @@ -3061,6 +3012,13 @@ function OnboardingWizardInner({ }} /> + {adapterRegistryLoaded && recommendedAdapters.length === 0 && ( +

+ Claude and Codex are unavailable on this instance. Ask an administrator to enable one, + or exit setup and choose New Agent on the Agents page to use another provider. +

+ )} + {/* Fades on the first beat but keeps its space until the second, so pressing a tile moves nothing vertically. Once a sign-in is running there is no switching to keys @@ -3132,33 +3090,6 @@ function OnboardingWizardInner({ {connectProgress}

- ) : isCopilot && createdCompanyId ? ( - - {!copilotEnvironmentReady ?

{loginEnvironmentResolution.error ?? "Waiting for execution environment settings. If this persists, check your instance settings."}

: <> - { - const parsed = aiConnectionBindingSchema.safeParse(binding); - if (!parsed.success || parsed.data.provider !== "github" || parsed.data.method !== "api_key") { - setError("Choose a GitHub Copilot token connection for this source."); - return; - } - setError(null); - setCopilotConnection(parsed.data); - setModel(""); - if (JSON.stringify(copilotConnection) === JSON.stringify(parsed.data)) void refreshAdapterModels(); - }} /> - {copilotConnection && } - } -
) : credentialMode === "api" ? ( } {/* Conditional adapter fields */} - {/* Direct adapters resolve their defaults. Copilot requires - an explicit authenticated model in its connection card. */} {/* Progress is shown above; failed checks remain actionable here. */} {/* Not while the hire is in flight. The probe's result lands