Limit first onboarding to Claude and Codex (#18309)

## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work.
> - First onboarding helps a new user connect the first agent.
> - Its provider picker currently includes GitHub Copilot.
> - The first setup should offer only Claude and Codex.
> - This pull request limits that picker to those two providers.
> - Users can still choose other providers when they add more agents.

## Linked Issues or Issue Description

**What existing behavior does this improve?**

The model-source picker in first-agent onboarding.

**Subsystem affected**

`ui/` — the onboarding wizard and its tests.

**Current behavior**

First onboarding offers GitHub Copilot. It can also expose other
providers when their display metadata marks them as recommended.

**Proposed behavior**

Show only enabled Claude and Codex choices in first onboarding. Keep
their automatic native-runner routing on qualified execution
environments and the silent legacy fallback on native Windows, with the
same provider UI. Keep all supported providers in the later Add Agent
flow. Require a visible provider choice when an old draft names a
provider that is no longer offered.

**Reason and benefit**

Keep the first setup focused while preserving provider choice for an
existing company.

**Breaking changes**

Old Copilot or other provider drafts require a new choice in first
onboarding. Existing agents, provider APIs, and the Add Agent flow keep
their current behavior.

**Additional context**

Related provider setup work: #15562. This change only narrows the
first-onboarding picker. Searches for onboarding/Copilot and
first-onboarding PRs found no duplicate change. This is a small
improvement to the existing setup flow, consistent with ROADMAP.md.

## What Changed

- Limit first-onboarding adapters to Claude and Codex, with existing
availability checks.
- Remove the Copilot tile, login card, connection state, model
discovery, and creation guards from first onboarding.
- Retain only migration handling for old Copilot drafts: clear the
retired selection/model/flag, restore automatic runner choice, and avoid
reusing GitHub bindings when the user selects Claude or Codex. This also
works when both onboarding providers are disabled.
- Update coverage for provider choices, disabled adapters, and old
Copilot and Gemini drafts.
- Clear the model when an unavailable saved provider changes to Codex.
- Explain how to proceed when both first-onboarding providers are
unavailable.
- Update the onboarding feature and access maps.
- Document the Windows fallback beside automatic runner discovery, with
an explicit note to default Windows to native when its runner support is
qualified. This inline migration note is an explicit requirement of the
change.
- Extend both providers' setup coverage for native and legacy
availability without forcing a runtime from the browser.

## Verification

- 282 tests passed across all onboarding component/route tests and
`NewAgentDialog.test.tsx`. This includes the separate adapter-draft
suite that CI identified as still expecting Copilot in first onboarding.
- The existing server runner-selection suite passed: 231 tests,
including native Claude/Codex runtime translation and Windows legacy
fallback. This is automated routing coverage; no native Windows
end-to-end run is claimed.
- UI TypeScript checks and the production UI build passed.
- Token gates, feature/access map validation, module-boundary checks,
and `git diff --check` passed.
- Reviewer command from `ui/`: `pnpm exec vitest run
src/components/OnboardingWizard.test.tsx
src/components/OnboardingWizard.adapters.test.tsx
src/components/OnboardingWizard.step.test.tsx
src/components/OnboardingWizardVariant.test.tsx
src/components/NewAgentDialog.test.tsx
src/lib/onboarding-route.test.ts`.
- A separate browser walkthrough on staging completed the Add Agent flow
with Codex and confirmed Copilot remained available. That walkthrough
used an existing deployment; it does not prove this branch's
first-onboarding change.
- Ponytail Paperclip Review and Greptile both scored the latest commit
`b470f7023ecb6657bd77edefbcaf2ab12fd89414` 5/5. All review threads are
resolved.
- All latest-head PR checks are complete: 55 passed and 3
neutral/skipped, with no failures or pending checks. Two server shards
passed on a single retry after an unchanged timing-sensitive test
exceeded its limit and a self-hosted runner lost its connection. No code
or test changes were made for the retry.

## Risks

- Low scope: the provider filter belongs only to the onboarding wizard.
Native Windows runner support is still unmerged (#18250 is only its
build/CI baseline), so automatic selection must retain the existing
legacy fallback. Platform selection is based on the execution target,
not the browser OS.
- Users with an old draft for another provider must choose Claude or
Codex. Existing agent configuration is not removed.
- Instances that disable both providers have no first-onboarding choice.
The wizard explains how to enable a provider or use New Agent from the
Agents page.

## Model Used

OpenAI GPT-6 through Codex. The exact runtime model ID and
context-window size are not exposed in this session. Capabilities used:
reasoning, repository edits, shell commands, and browser UI
verification.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] All Paperclip CI gates are green
- [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge

---------

Co-authored-by: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip authored and GitHub committed 2026-10-30 10:04:38 -05:00
1 parent fba0ab8162
commit 59e191c368
5 files changed
+202 -278

No files matched your search

+4
View File
@@ -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
+14
View File
@@ -36,6 +36,20 @@ Use `/<company-prefix>/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.
@@ -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());
}
});
});
+127 -147
View File
@@ -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<typeof import("./ai-connections/AiConnectionField")>();
return { ...actual, AiConnectionField: (props: { environmentId?: string; onChange: (value: import("@paperclipai/shared").AiConnectionBinding) => void }) => (
<button data-environment={props.environmentId} onClick={() => props.onChange({ provider: "github", method: "api_key", mode: "responsible_user" })}>Use saved Copilot connection</button>
) };
});
vi.mock("../api/auth", async (importOriginal) => {
const actual = await importOriginal<typeof import("../api/auth")>();
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<string, unknown> = {}) {
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<string, unknown> = {}) {
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<string, unknown>; 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(<QueryClientProvider client={queryClient}><OnboardingWizard /></QueryClientProvider>));
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<string, unknown>; 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<import("../api/adapters").AdapterInfo[]>(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<string, unknown> }];
const hired = mockAgentsApi.hire.mock.calls[0] as unknown as [string, { adapterConfig: Record<string, unknown> }];
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");
+48 -119
View File
@@ -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<string, ComponentType<{ className?: stri
const API_KEY_ENV_KEYS: Record<string, string> = {
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<AdapterType>(() =>
restoreOnboardingAdapterType(saved?.adapterType, saved?.runnerProvider, saved?.onboardingCopilot),
);
const [runner, setRunner] = useState<AgentRunnerChoice>(() => saved?.runner === "legacy" || saved?.runner === "paperclip"
const [runner, setRunner] = useState<AgentRunnerChoice>(() => 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<string | null>(null);
const isCopilot = adapterType === "paperclip_runner";
const [copilotConnection, setCopilotConnection] = useState<AiConnectionBinding>();
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<ReturnType<typeof storeProviderApiKey>>["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<string, unknown> | 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<string, unknown> | undefined)?.[apiKeyEnvKeyFor(adapterType)]) setCredentialMode("api");
else if ((config.env as Record<string, unknown> | undefined)?.[CLAUDE_OAUTH_TOKEN_ENV_KEY]
|| (config.env as Record<string, unknown> | 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<string, string> = {
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<string, unknown> {
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 && (
<motion.div key="step-4" {...stepContentMotion} exit={stepHandoff ? stepContentMotion.exit : undefined} className="space-y-8">
<div>
{/* 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. */}
<ModelSourceTiles
label="Model source"
sources={recommendedAdapters.map((opt) => ({
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: <ModelSourceMark type={opt.type} Fallback={opt.icon} />,
...(opt.type === "paperclip_runner" ? { credentialMode: "api" as const } : {}),
}))}
mode={credentialMode}
selectedId={
@@ -3061,6 +3012,13 @@ function OnboardingWizardInner({
}}
/>
{adapterRegistryLoaded && recommendedAdapters.length === 0 && (
<p role="status" className="text-sm text-muted-foreground">
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.
</p>
)}
{/* 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({
<Loader2 className="size-4 animate-spin" />
{connectProgress}
</p>
) : isCopilot && createdCompanyId ? (
<OnboardingLoginCard instruction="Connect GitHub Copilot and choose a model">
{!copilotEnvironmentReady ? <p role="alert" className="text-sm text-destructive">{loginEnvironmentResolution.error ?? "Waiting for execution environment settings. If this persists, check your instance settings."}</p> : <>
<AiConnectionField companyId={createdCompanyId} agentName={agentName} adapterType="copilot_runtime" environmentId={resolvedLoginEnvironmentId ?? undefined} value={copilotConnection} onChange={(binding) => {
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 && <label className="block space-y-2 text-sm">
<span>Model</span>
<select aria-label="Copilot model" className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm" value={model} disabled={adapterModelsFetching} onChange={(event) => setModel(event.target.value)}>
<option value="">{adapterModelsFetching ? "Loading models…" : "Select a model"}</option>
{model && !adapterModels?.some((entry) => entry.id === model) && <option value={model}>{model} (unavailable)</option>}
{(adapterModels ?? []).map((entry) => <option key={entry.id} value={entry.id}>{entry.label}</option>)}
</select>
{adapterModelsError && <p role="alert" className="text-destructive">{adapterModelsError instanceof Error ? adapterModelsError.message : "Could not discover Copilot models."}</p>}
{!adapterModelsFetching && !adapterModelsError && adapterModels?.length === 0 && <p role="alert" className="text-destructive">No available Copilot models were discovered. Check this account’s Copilot access.</p>}
<Button type="button" variant="ghost" disabled={adapterModelsFetching} onClick={() => void refreshAdapterModels()}>Refresh models</Button>
</label>}
</>}
</OnboardingLoginCard>
) : credentialMode === "api" ? (
<OnboardingLoginCard
instruction={hasGatewaySelection || connectionModels ? "Choose a model for your saved connection" : savedKeys.options.length ? "Choose a saved API key or enter a new one" : `Provide your ${
@@ -3324,8 +3255,6 @@ function OnboardingWizardInner({
</details>}
{/* 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