Finish Copilot production setup on current master

Preserve the reviewed candidate source and mainline provider behavior.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-10-09 10:54:11 -05:00
1 parent cc1670aa56
commit aaa65bbfbf
74 files changed
+57873 -251

No files matched your search

+10 -2
View File
@@ -902,10 +902,14 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
const modelProvider = adapterType === "opencode_local" && aiConnectionBindingSchema.safeParse(
(overlay.runtime.runtimeConfig as Record<string, unknown> | undefined)?.aiConnection ?? runtimeConfig.aiConnection,
).data?.provider === "openrouter" ? "openrouter" : runnerProvider;
const runnerAcpxAgent = adapterType === "paperclip_runner" && runnerProvider === "acpx"
? String(isCreate ? props.values.adapterSchemaValues?.acpxAgent ?? "claude" : eff("adapterConfig", "acpxAgent", config.acpxAgent ?? "claude")) : undefined;
const modelAiBinding = aiConnectionBindingSchema.safeParse((overlay.runtime.runtimeConfig as Record<string, unknown> | undefined)?.aiConnection ?? runtimeConfig.aiConnection).data;
const modelCacheIdentity = runnerAcpxAgent === "copilot" ? `${modelProvider}:${runnerAcpxAgent}:${JSON.stringify(modelAiBinding ?? null)}` : modelProvider;
const connectionModels = useConnectionModels(selectedCompanyId, isCreate ? undefined : aiConnectionBindingSchema.safeParse((overlay.runtime.runtimeConfig as Record<string, unknown> | undefined)?.aiConnection ?? runtimeConfig.aiConnection).data, aiRoutingHarness(adapterType, runnerProvider, eff("adapterConfig", "acpxAgent", config.acpxAgent)));
// Fetch adapter models for the effective provider, including unsaved changes.
const modelQueryKey = selectedCompanyId
? queryKeys.agents.adapterModels(selectedCompanyId, adapterType, currentDefaultEnvironmentId || null, modelProvider)
? queryKeys.agents.adapterModels(selectedCompanyId, adapterType, currentDefaultEnvironmentId || null, modelCacheIdentity)
: ["agents", "none", "adapter-models", adapterType];
const {
data: fetchedModels,
@@ -916,6 +920,9 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
queryFn: () => agentsApi.adapterModels(selectedCompanyId!, adapterType, {
environmentId: currentDefaultEnvironmentId || null,
provider: modelProvider,
acpxAgent: runnerAcpxAgent,
aiConnection: modelAiBinding,
agentId: isCreate ? undefined : props.agent.id,
}),
enabled: Boolean(selectedCompanyId) && !connectionModels,
});
@@ -1271,7 +1278,7 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
setRefreshingModels(true);
setRefreshModelsError(null);
try {
const refreshed = await agentsApi.adapterModels(selectedCompanyId, adapterType, { refresh: true, environmentId: currentDefaultEnvironmentId || null, provider: modelProvider });
const refreshed = await agentsApi.adapterModels(selectedCompanyId, adapterType, { refresh: true, environmentId: currentDefaultEnvironmentId || null, provider: modelProvider, ...(runnerAcpxAgent === "copilot" ? { acpxAgent: runnerAcpxAgent, aiConnection: modelAiBinding, agentId: !isCreate ? props.agent.id : undefined } : {}) });
queryClient.setQueryData(modelQueryKey, refreshed);
} catch (error) {
setRefreshModelsError(error instanceof Error ? error.message : "Failed to refresh adapter models.");
@@ -2169,6 +2176,7 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
{props.compactTestFeedback && showInlineAdapterTestEnvironmentFeedback && showAdapterTestEnvironmentButton && (
<RuntimeTestCard
variant={isDotRunner ? "prerequisites" : "connection"}
metadataOnly={adapterType === "paperclip_runner" && runnerAcpxAgent === "copilot"}
state={testActionPending ? "running" : testActionError || testEnvironment.error ? "fail" : testResult?.status ?? "idle"}
result={testResult ?? null}
error={testActionError ?? (testEnvironment.error instanceof Error ? testEnvironment.error.message : null)}
@@ -2359,6 +2359,23 @@ describe("IssueProperties", () => {
act(() => root.unmount());
});
it("discovers Copilot overrides with the assignee connection and environment", async () => {
const binding = { provider: "github", method: "api_key", mode: "shared", connectionId: "11111111-1111-4111-8111-111111111111", grantId: "33333333-3333-4333-8333-333333333333" };
mockAgentsApi.list.mockResolvedValue([{ id: "agent-1", name: "Copilot", role: "engineer", status: "active",
adapterType: "paperclip_runner", defaultEnvironmentId: "daytona-1",
adapterConfig: { provider: "acpx", acpxAgent: "copilot", model: "gpt-5.6-luna" },
runtimeConfig: { aiConnection: binding } }]);
mockAgentsApi.adapterModels.mockResolvedValue([{ id: "gpt-5.6-luna", label: "Copilot Luna" }]);
const root = renderProperties(container, {
issue: createIssue({ assigneeAgentId: "agent-1", assigneeAdapterOverrides: { adapterConfig: { model: "gpt-5.6-luna" } } }),
childIssues: [], onUpdate: vi.fn(),
});
await waitForAssertion(() => expect(mockAgentsApi.adapterModels).toHaveBeenCalledWith("company-1", "paperclip_runner", {
environmentId: "daytona-1", provider: "acpx", acpxAgent: "copilot", agentId: "agent-1", aiConnection: binding,
}));
act(() => root.unmount());
});
it("edits existing custom assignee model options from the properties pane", async () => {
const onUpdate = vi.fn();
mockAgentsApi.list.mockResolvedValue([
+12
View File
@@ -116,9 +116,21 @@ it("offers native Codex, Claude ACPX, and OpenCode runners", async () => {
expect(options).toContain("Codex (app server)");
expect(options).toContain("Claude (ACPX)");
expect(options).toContain("OpenCode");
expect(options).toContain("GitHub Copilot");
expect(options.join(" ")).not.toContain("ACPX Codex");
});
it("opens saved-token Copilot setup through the ordinary New Agent picker", async () => {
await name();
await act(async () => (document.querySelector('input[value="paperclip_runner"]') as HTMLInputElement).click());
const select = document.querySelector("select") as HTMLSelectElement;
await act(async () => { select.value = "copilot"; select.dispatchEvent(new Event("change", { bubbles: true })); });
await click("Configure agent");
const query = new URL(state.navigate.mock.calls[0][0], "http://local").searchParams;
expect(query.get("adapterType")).toBe("paperclip_runner");
expect(query.get("runnerProvider")).toBe("copilot");
});
it.each([false, undefined])(
"hides the runner unless explicitly enabled (%s)",
async (enableNativeRunner) => {
@@ -111,7 +111,7 @@ async function flushReact() {
});
}
async function mount() {
async function mount(nativeRunnerEnabled?: boolean) {
const container = document.createElement("div");
document.body.appendChild(container);
const root = createRoot(container);
@@ -124,6 +124,7 @@ async function mount() {
session: { id: "session-1", userId: "user-1" },
user: { id: "user-1", name: "Example", email: "user-1@example.com", image: null },
});
if (nativeRunnerEnabled !== undefined) queryClient.setQueryData(queryKeys.instance.experimentalSettings, { enableNativeRunner: nativeRunnerEnabled, enableOpenAiDot: true });
await act(async () => {
root.render(
<QueryClientProvider client={queryClient}>
@@ -287,4 +288,21 @@ describe("OnboardingWizard adapter selection", () => {
root.unmount();
});
});
it("does not offer or retain Copilot when only OpenAI Dot is enabled", async () => {
mockAdapterRegistry.list = [{ type: "paperclip_runner" }, { type: "codex_local" }];
window.localStorage.setItem(ONBOARDING_STORAGE_KEY, JSON.stringify({ step: 0, adapterType: "paperclip_runner", onboardingCopilot: true, model: "gpt-5.6-luna" }));
const { root, container } = await mount(false);
expect(JSON.parse(window.localStorage.getItem(ONBOARDING_STORAGE_KEY)!)).toMatchObject({ adapterType: "codex_local", onboardingCopilot: false, model: "" });
expect(container.textContent).not.toContain("GitHub Copilot");
await act(async () => root.unmount());
});
it("restores a qualified Copilot draft without changing its explicit model", async () => {
mockAdapterRegistry.list = [{ type: "paperclip_runner" }];
window.localStorage.setItem(ONBOARDING_STORAGE_KEY, JSON.stringify({ step: 0, adapterType: "paperclip_runner", onboardingCopilot: true, model: "gpt-5.6-luna" }));
const { root } = await mount(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());
});
});
+84 -1
View File
@@ -25,6 +25,15 @@ 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 } };
@@ -138,7 +147,7 @@ const mockEnvironmentsApi = vi.hoisted(() => ({
}));
const mockInstanceSettingsApi = vi.hoisted(() => ({
get: vi.fn(async () => ({ defaultEnvironmentId: null as string | null })),
getExperimental: vi.fn(async () => ({ enableManagedSandboxOnly: false })),
getExperimental: vi.fn(async (): Promise<{ enableManagedSandboxOnly: boolean; enableNativeRunner?: boolean }> => ({ enableManagedSandboxOnly: false })),
}));
const mockApprovalsApi = vi.hoisted(() => ({
create: vi.fn(),
@@ -325,6 +334,7 @@ function isArcPrimary(text: string): boolean {
describe("OnboardingWizard restore-gate (stale localStorage across accounts)", () => {
beforeEach(() => {
mockInstanceSettingsApi.getExperimental.mockResolvedValue({ enableManagedSandboxOnly: false });
localHealth.get.mockResolvedValue({ deploymentMode: "authenticated" });
managedApi.checkLocalLogin.mockReset().mockResolvedValue({
status: "sign_in_required",
@@ -374,6 +384,7 @@ describe("OnboardingWizard restore-gate (stale localStorage across accounts)", (
// call history — so a test that customizes one of these must not leak
// its override into the next test.
mockAgentsApi.testEnvironment.mockReset();
mockAgentsApi.adapterModels.mockReset().mockResolvedValue([]);
mockAgentsApi.testEnvironment.mockResolvedValue({
adapterType: "claude_local",
status: "pass" as const,
@@ -401,6 +412,78 @@ describe("OnboardingWizard restore-gate (stale localStorage across accounts)", (
vi.clearAllMocks();
});
describe("qualified Copilot onboarding", () => {
async function openCopilot() {
mockInstanceSettingsApi.getExperimental.mockResolvedValue({ enableManagedSandboxOnly: false, enableNativeRunner: true });
mockCompany.companies = [{ id: "c1", name: "Canary", issuePrefix: "CAN" }];
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" }));
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 };
return { ...values.adapterSchemaValues, model: values.model };
});
const { root, queryClient } = render();
queryClient.setQueryData(queryKeys.instance.experimentalSettings, { enableManagedSandboxOnly: false, enableNativeRunner: true });
await act(async () => root.render(<QueryClientProvider client={queryClient}><OnboardingWizard /></QueryClientProvider>));
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 };
}
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("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());
});
});
describe("step 1 leads straight to the agent — there is no mission step 2", () => {
// One path now: Name your organization → Name your agent → Connect → Get
// started. The Build / Grow front door and both mission screens are gone,
+106 -61
View File
@@ -2,7 +2,9 @@ import { healthApi } from "@/api/health";
import { LocalProviderLoginInstructions } from "./AdapterLoginChrome";
import { useLocalAiLogin } from "./ai-connections/useLocalAiLogin";
import { aiConnectionsApi } from "@/api/ai-connections";
import { aiProviderForAdapter } from "./ai-connections/AiConnectionField";
import { AiConnectionField, aiProviderForAdapter } from "./ai-connections/AiConnectionField";
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";
@@ -147,17 +149,20 @@ type Step = 0 | 1 | 2 | 3 | 4 | 5;
// wizard's registry-driven approach rather than a fixed union.
type AdapterType = string;
// First-run onboarding stays on the proven direct adapters even when an
// instance administrator has opted into Paperclip Runner elsewhere. The
// experimental flag only exposes the runner in explicit agent configuration.
// 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",
]);
function restoreOnboardingAdapterType(savedAdapterType: unknown): AdapterType {
return typeof savedAdapterType === "string" && savedAdapterType !== "paperclip_runner"
const copilotOnboardingQualified = PAPERCLIP_RUNNER_ACPX_PROFILES.some(
(profile) => profile.value === "copilot" && profile.qualified,
);
function restoreOnboardingAdapterType(savedAdapterType: unknown, savedCopilot: unknown): AdapterType {
return typeof savedAdapterType === "string" && (savedAdapterType !== "paperclip_runner" || (savedCopilot === true && copilotOnboardingQualified))
? savedAdapterType
: "claude_local";
}
@@ -251,6 +256,7 @@ 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 {
@@ -594,7 +600,7 @@ function OnboardingWizardInner({
(saved?.agentRole as AgentRole) || DEFAULT_AGENT_ROLE,
);
const [adapterType, setAdapterType] = useState<AdapterType>(() =>
restoreOnboardingAdapterType(saved?.adapterType),
restoreOnboardingAdapterType(saved?.adapterType, saved?.onboardingCopilot),
);
/**
* Whether a model source has been chosen, as opposed to which one
@@ -619,7 +625,9 @@ function OnboardingWizardInner({
* whether the row has been *answered* on this visit.
*/
const [sourcePicked, setSourcePicked] = useState(false);
const savedNativeRunnerDraft = saved?.adapterType === "paperclip_runner";
const isCopilot = adapterType === "paperclip_runner";
const [copilotConnection, setCopilotConnection] = useState<AiConnectionBinding>();
const savedNativeRunnerDraft = saved?.adapterType === "paperclip_runner" && !(saved?.onboardingCopilot === true && copilotOnboardingQualified);
const [cwd, setCwd] = useState((saved?.cwd as string) ?? "");
// Native drafts may carry provider-specific configuration that is invalid
// for the legacy adapter selected above. Keep the portable working
@@ -628,13 +636,13 @@ function OnboardingWizardInner({
savedNativeRunnerDraft ? "" : (saved?.model as string) ?? "",
);
const [command, setCommand] = useState(
savedNativeRunnerDraft ? "" : (saved?.command as string) ?? "",
saved?.adapterType === "paperclip_runner" ? "" : (saved?.command as string) ?? "",
);
const [args, setArgs] = useState(
savedNativeRunnerDraft ? "" : (saved?.args as string) ?? "",
saved?.adapterType === "paperclip_runner" ? "" : (saved?.args as string) ?? "",
);
const [url, setUrl] = useState(
savedNativeRunnerDraft ? "" : (saved?.url as string) ?? "",
saved?.adapterType === "paperclip_runner" ? "" : (saved?.url as string) ?? "",
);
const [adapterEnvResult, setAdapterEnvResult] =
useState<AdapterEnvironmentTestResult | null>(null);
@@ -708,7 +716,7 @@ function OnboardingWizardInner({
? selectedSavedKey.id
: savedKeys.options[0]?.id;
const selectedApiKey = savedKeys.options.find((option) => option.id === selectedApiKeyId);
const credentialMode = credentialModeChoice ?? (
const credentialMode = isCopilot ? "api" : credentialModeChoice ?? (
(savedKeys.subscriptions.length > 0 || (adapterType === "claude_local" && savedKeys.storedLogin.data))
? "subscription" : savedKeys.options.length || adapterType === "opencode_local" ? "api" : "subscription"
);
@@ -761,9 +769,10 @@ 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(adapterType);
const managedProvider = aiProviderForAdapter(isCopilot ? "copilot_runtime" : adapterType);
const managedSubscriptionProvider = managedProvider === "anthropic" || managedProvider === "openai" || managedProvider === "xai" ? managedProvider : undefined;
function managedBindingForStep(): AiConnectionBinding | undefined {
if (isCopilot) return copilotConnection;
if (credentialMode === "api") return selectedApiKey?.aiConnection ?? (
!selectedApiKey && apiKeySecretRef.current?.companyId === createdCompanyId && apiKeySecretRef.current.envKey === apiKeyEnvKeyFor(adapterType)
? apiKeySecretRef.current.aiConnection : undefined);
@@ -800,6 +809,7 @@ function OnboardingWizardInner({
* hand rather than the one before it.
*/
function clearCompanyScopedState() {
setCopilotConnection(undefined);
setAgentAppearance(randomAgentAppearance());
setCreatedCompanyPrefix(null);
setCompanyName("");
@@ -905,6 +915,7 @@ function OnboardingWizardInner({
const state = {
step, companyName,
agentName, agentAppearance, agentRole, adapterType, cwd, model, command, args, url,
onboardingCopilot: isCopilot,
// The mode, never the key: this blob is localStorage.
credentialMode, credentialModeChoice,
createdCompanyId, createdCompanyPrefix, createdAgentId,
@@ -919,21 +930,6 @@ function OnboardingWizardInner({
createdCompanyGoalId, createdProjectId, createdIssueRef,
]);
const {
data: adapterModels,
error: adapterModelsError,
isLoading: adapterModelsLoading,
isFetching: adapterModelsFetching
} = useQuery({
// The wizard doesn't expose an environment selector, so models always
// resolve against the local Paperclip host (environmentId = null).
queryKey: createdCompanyId
? queryKeys.agents.adapterModels(createdCompanyId, adapterType, null)
: ["agents", "none", "adapter-models", adapterType, null],
queryFn: () => agentsApi.adapterModels(createdCompanyId!, adapterType, { environmentId: null }),
// Models are picked on step 4 (Connect a model).
enabled: Boolean(createdCompanyId) && effectiveOnboardingOpen && step === 4
});
const getCapabilities = useAdapterCapabilities();
const adapterCaps = getCapabilities(adapterType);
@@ -943,14 +939,14 @@ function OnboardingWizardInner({
// managed-sandbox-only redirect (see AgentConfigForm.tsx:618-640). A render
// must not throw, so a resolver error yields no login environment rather
// than an error boundary.
const { data: loginEnvironmentList = [] } = useQuery({
const { data: loginEnvironmentList = [], isSuccess: loginEnvironmentsReady } = useQuery({
queryKey: createdCompanyId
? queryKeys.environments.list(createdCompanyId)
: ["environments", "none"],
queryFn: () => environmentsApi.list(createdCompanyId!),
enabled: Boolean(createdCompanyId) && effectiveOnboardingOpen && step === 4,
});
const { data: instanceSettingsForLogin } = useQuery({
const { data: instanceSettingsForLogin, isSuccess: loginSettingsReady } = useQuery({
queryKey: queryKeys.instance.settings,
queryFn: () => instanceSettingsApi.get(),
enabled: effectiveOnboardingOpen && step === 4,
@@ -958,29 +954,50 @@ function OnboardingWizardInner({
// Wanted across the whole arc, not just the connect step. The progress strip
// reads it too — see `enteredFromCloud` — and a value fetched only on step 4
// would let the strip change length as the customer walked through it.
const { data: experimentalSettingsForLogin } = useQuery({
const { data: experimentalSettingsForLogin, isSuccess: loginPolicyReady } = useQuery({
queryKey: queryKeys.instance.experimentalSettings,
queryFn: () => instanceSettingsApi.getExperimental(),
enabled: effectiveOnboardingOpen && step >= 3 && step <= 5,
});
const resolvedLoginEnvironmentId = useMemo(() => {
const copilotOnboardingEnabled = copilotOnboardingQualified && experimentalSettingsForLogin?.enableNativeRunner === true;
const loginEnvironmentResolution = useMemo(() => {
try {
return resolveAdapterTestEnvironmentId({
return { environmentId: resolveAdapterTestEnvironmentId({
agentDefaultEnvironmentId: null,
instanceDefaultEnvironmentId: instanceSettingsForLogin?.defaultEnvironmentId ?? null,
localDefaultEnvironmentId: resolveLocalDefaultEnvironmentId(loginEnvironmentList),
managedSandboxOnly: experimentalSettingsForLogin?.enableManagedSandboxOnly === true,
managedSandboxEnvironmentId: resolveManagedSandboxEnvironmentId(loginEnvironmentList),
visibleEnvironmentIds: loginEnvironmentList.map((environment) => environment.id),
});
} catch {
return null;
}), error: null };
} catch (error) {
return { environmentId: null, error: error instanceof Error ? error.message : "Could not resolve the execution environment." };
}
}, [
instanceSettingsForLogin?.defaultEnvironmentId,
loginEnvironmentList,
experimentalSettingsForLogin?.enableManagedSandboxOnly,
]);
const resolvedLoginEnvironmentId = loginEnvironmentResolution.environmentId;
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 } : {}),
}),
enabled: Boolean(createdCompanyId) && effectiveOnboardingOpen && step === 4 && (!isCopilot || Boolean(copilotConnection && copilotEnvironmentReady)),
retry: false,
});
const copilotModelReady = Boolean(copilotConnection && copilotEnvironmentReady && !adapterModelsError && !adapterModelsFetching && adapterModels?.some((entry) => entry.id === model.trim()));
const resolvedLoginEnvironment = useMemo(
() =>
loginEnvironmentList.find((environment) => environment.id === resolvedLoginEnvironmentId) ??
@@ -1123,7 +1140,7 @@ function OnboardingWizardInner({
adapterCaps.supportsSkills ||
adapterCaps.supportsLocalAgentJwt;
const isLocalAdapter =
isLocalAdapterCaps ||
isLocalAdapterCaps || isCopilot ||
adapterType === "claude_local" ||
adapterType === "codex_local" ||
adapterType === "gemini_local" ||
@@ -1143,11 +1160,14 @@ function OnboardingWizardInner({
)
.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),
recommendedAdapters: [...all.filter((a) => a.recommended), ...copilot],
moreAdapters: all.filter((a) => !a.recommended),
};
}, [disabledTypes]);
}, [disabledTypes, copilotOnboardingEnabled]);
/**
* A source chosen from the visible row. Read off the row rather than off
@@ -1174,7 +1194,7 @@ function OnboardingWizardInner({
* Anything that gates this step belongs in here, so the next one is added
* once rather than twice.
*/
const connectStepReady = sourceSelected && !adapterEnvLoading && !savedKeys.loading;
const connectStepReady = sourceSelected && !adapterEnvLoading && !savedKeys.loading && (!isCopilot || copilotModelReady);
/**
* Whether this step has a sign-in to do before it can hire.
@@ -1366,7 +1386,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 = CONNECT_SOURCE_NAMES[adapterType] ?? adapterType;
const connectSourceLabel = isCopilot ? "GitHub Copilot" : CONNECT_SOURCE_NAMES[adapterType] ?? adapterType;
const connectCta: { label: string; icon: FooterPrimaryIcon; disabled: boolean } =
connectProgress
? { label: adapterEnvLoading ? "Testing…" : connectProgress, icon: "spinner", disabled: true }
@@ -1385,7 +1405,7 @@ function OnboardingWizardInner({
label: "Connect",
icon: "arrow",
disabled:
!connectStepReady || (credentialMode === "api" && !apiKey.trim() && !selectedApiKey),
!connectStepReady || (!isCopilot && credentialMode === "api" && !apiKey.trim() && !selectedApiKey),
}
: // Nothing is chosen on arrival, and the row is what chooses. Until
// it has been answered the button has nothing to do.
@@ -1482,14 +1502,18 @@ function OnboardingWizardInner({
// registered once the adapters query resolves, so before that a saved
// external adapter is indistinguishable from a disabled one - and snapping
// would replace the customer's choice with a built-in and persist it.
if (!adapterRegistryLoaded) return;
if (!adapterRegistryLoaded || (adapterType === "paperclip_runner" && !loginPolicyReady)) return;
const visible = [...recommendedAdapters, ...moreAdapters].filter(
(a) => !a.comingSoon,
);
if (visible.length === 0) return;
if (visible.length === 0) {
if (adapterType === "paperclip_runner" && !copilotOnboardingEnabled) { setAdapterType("claude_local"); setModel(""); setCopilotConnection(undefined); setSourcePicked(false); }
return;
}
if (visible.some((a) => a.type === adapterType)) return;
const next = visible[0].type as AdapterType;
const next = (visible.find((a) => a.type !== "paperclip_runner") ?? visible[0]).type as AdapterType;
setAdapterType(next);
if (adapterType === "paperclip_runner") { setModel(""); setCopilotConnection(undefined); }
// The snap is not a choice. It replaces a name the customer can no longer
// see with the first one they can, which is the right thing to hold — but
// holding it *as chosen* would put a filled tile and an open sign-in panel
@@ -1511,7 +1535,7 @@ function OnboardingWizardInner({
return;
}
setModel("");
}, [adapterRegistryLoaded, recommendedAdapters, moreAdapters, adapterType]);
}, [adapterRegistryLoaded, recommendedAdapters, moreAdapters, adapterType, loginPolicyReady, copilotOnboardingEnabled]);
const COMMAND_PLACEHOLDERS: Record<string, string> = {
claude_local: "claude",
@@ -1541,7 +1565,7 @@ function OnboardingWizardInner({
setAdapterEnvResult(null);
adapterEnvResultAppliedStoredLoginRef.current = false;
setAdapterEnvError(null);
}, [step, adapterType, model, command, args, url, credentialMode, apiKey, selectedSavedKey, selectedApiKey?.id, savedSubscription?.id]);
}, [step, adapterType, model, command, args, url, credentialMode, apiKey, selectedSavedKey, selectedApiKey?.id, savedSubscription?.id, copilotConnection, resolvedLoginEnvironmentId]);
/**
* Leaving the step puts the row back to a question.
@@ -1639,6 +1663,7 @@ function OnboardingWizardInner({
setForceUnsetAnthropicApiKey(false);
setUnsetAnthropicLoading(false);
setClaudeOAuthStatus(null);
setCopilotConnection(undefined);
setCreatedCompanyId(null);
setCreatedCompanyPrefix(null);
setCreatedAgentId(null);
@@ -1821,6 +1846,7 @@ function OnboardingWizardInner({
const config = adapter.buildAdapterConfig({
...defaultCreateValues,
adapterType,
...(isCopilot ? { adapterSchemaValues: { provider: "acpx", acpxAgent: "copilot" } } : {}),
model:
adapterType === "gemini_local"
? model || DEFAULT_GEMINI_LOCAL_MODEL
@@ -2018,13 +2044,8 @@ function OnboardingWizardInner({
// doesn't hire a second agent.
async function handleGiveHeartbeat() {
if (!createdCompanyId) return;
// The grid and restore path both exclude native runner. Keep this final
// guard at the mutation boundary so a stale or modified client cannot use
// first-run onboarding to create a native agent.
if (adapterType === "paperclip_runner") {
setAdapterType("claude_local");
setModel("");
setError("Paperclip Runner is not available during onboarding. Choose a legacy adapter.");
if (isCopilot && (!copilotOnboardingEnabled || !sourceSelected || !copilotModelReady)) {
setError("Connect GitHub Copilot and select an available model before creating this agent.");
return;
}
if (createdAgentId) {
@@ -2090,7 +2111,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 (credentialMode === "api" && !selectedApiKey && apiKey.trim()) {
if (!isCopilot && credentialMode === "api" && !selectedApiKey && apiKey.trim()) {
apiKeyStored = await storeApiKeyUserSecret(createdCompanyId);
if (!apiKeyStored || !isCurrent()) return;
}
@@ -2675,8 +2696,9 @@ function OnboardingWizardInner({
label="Model source"
sources={recommendedAdapters.map((opt) => ({
id: opt.type,
label: CONNECT_SOURCE_NAMES[opt.type] ?? opt.label,
label: opt.type === "paperclip_runner" ? "GitHub Copilot" : 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={
@@ -2769,6 +2791,32 @@ 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("");
}} />
{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={savedKeys.options.length ? "Choose a saved API key or enter a new one" : `Provide your ${
@@ -2897,11 +2945,8 @@ function OnboardingWizardInner({
</motion.div>
{/* Conditional adapter fields */}
{/* No model picker. Every adapter this step offers resolves
its own default (see buildAdapterConfig), so the picker
asked the customer to choose a model before they had any
way to judge one — and the agent's model is changeable
later, where its work gives the choice meaning. */}
{/* 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
+8 -1
View File
@@ -53,6 +53,7 @@ export function RuntimeTestCard({
onTest,
disabled = false,
variant = "connection",
metadataOnly = false,
}: {
state: TestState | "warn";
result: AdapterEnvironmentTestResult | null;
@@ -60,8 +61,14 @@ export function RuntimeTestCard({
onTest: () => void;
disabled?: boolean;
variant?: "connection" | "prerequisites";
metadataOnly?: boolean;
}) {
const content = (variant === "prerequisites" ? prerequisiteCopy : copy)[state];
const description = metadataOnly && state === "idle"
? "Verify your saved token, runtime installation, and selected model without sending a model prompt."
: metadataOnly && state === "running"
? "Checking the saved token, runtime, and selected model…"
: content.description;
const Icon =
state === "running"
? Loader2
@@ -102,7 +109,7 @@ export function RuntimeTestCard({
>
<h3 className="text-sm font-medium">{content.title}</h3>
<p className="text-xs leading-relaxed text-muted-foreground">
{state === "fail" && error ? error : content.description}
{state === "fail" && error ? error : description}
</p>
</div>
<Button
@@ -34,7 +34,7 @@ type Props = {
/** Connections hosts the same provider step as agent setup, with its own save intent. */
export function AiConnectionCredentialStep(props: Props) {
if (props.provider === "openrouter" || props.provider === "google") return <ApiKeyConnectionStep {...props} />;
if (props.provider === "openrouter" || props.provider === "github" || props.provider === "google") return <ApiKeyConnectionStep {...props} />;
return <SubscriptionConnectionStep {...props} />;
}
@@ -100,20 +100,33 @@ function SubscriptionConnectionStep({ companyId, provider, initialMethod, fixedM
</div>;
}
function ApiKeyConnectionStep({ companyId, provider, connectionId, name: initialName, hideName, nameForMethod, ownership, agentIds, allAgents, onComplete, onCancel, defaults, disabled }: Props) {
function ApiKeyConnectionStep({ companyId, provider, connectionId, name: initialName, hideName, nameForMethod, ownership, agentIds, allAgents, environmentId: suppliedEnvironmentId, defaults, disabled, onComplete, onCancel }: Props) {
const [name, setName] = useState(initialName);
const [apiKey, setApiKey] = useState("");
const client = useQueryClient();
const [chosenEnvironment, setChosenEnvironment] = useState<string>();
const copilot = provider === "github";
const envs = useQuery({ queryKey: queryKeys.environments.list(companyId), queryFn: () => environmentsApi.list(companyId), enabled: copilot });
const settings = useQuery({ queryKey: queryKeys.instance.settings, queryFn: instanceSettingsApi.get, enabled: copilot });
const environmentId = suppliedEnvironmentId ?? chosenEnvironment ?? settings.data?.defaultEnvironmentId ?? resolveLocalDefaultEnvironmentId(envs.data);
const environmentError = copilot ? envs.error?.message ?? settings.error?.message : undefined;
const preparing = copilot && (envs.isPending || settings.isPending);
const save = useMutation({
mutationFn: () => aiConnectionsApi.create(companyId, { provider, method: "api_key", name: connectionId ? name : nameForMethod?.("api_key") ?? name, ownership, agentIds, allAgents, connectionId, apiKey }),
mutationFn: () => aiConnectionsApi.create(companyId, { provider, method: "api_key", name: connectionId ? name : nameForMethod?.("api_key") ?? name, ownership, agentIds, allAgents, connectionId, apiKey, ...(copilot ? { environmentId } : {}) }),
onSuccess: (result) => { void client.invalidateQueries({ queryKey: ["ai-connections", companyId] }); void client.invalidateQueries({ queryKey: ["tools"] }); onComplete({ ...result, method: "api_key" }); },
onSettled: () => setApiKey(""),
});
return <div className="mx-auto w-full min-w-0 max-w-xl space-y-4">
{!hideName && <label className="block space-y-2 text-sm">Connection name<Input value={name} onChange={(event) => setName(event.target.value)} disabled={Boolean(connectionId)} /></label>}
{save.error && <p role="alert" className="text-sm text-destructive">{save.error.message}</p>}
<ProviderApiKeyCard providerName={provider === "google" ? "Google" : "OpenRouter"} value={apiKey} onChange={setApiKey} onSubmit={() => save.mutate()} disabled={disabled || save.isPending} placeholder="Enter API key here" autoFocus />
{copilot && <p className="text-sm text-muted-foreground">Use a personal fine-grained token with Copilot Requests permission. <a className="underline" href="https://docs.github.com/en/copilot/how-tos/copilot-cli/set-up-copilot-cli/authenticate-copilot-cli" target="_blank" rel="noreferrer">GitHub token instructions</a>. Verification discovers models in the selected environment and sends no model prompt. Repository access uses a separate connection.</p>}
{copilot && !suppliedEnvironmentId && <Select value={environmentId ?? ""} onValueChange={setChosenEnvironment} disabled={preparing || save.isPending}>
<SelectTrigger aria-label="Copilot execution environment"><SelectValue placeholder="Execution environment" /></SelectTrigger>
<SelectContent>{(envs.data ?? []).filter(env => env.status === "active").map(env => <SelectItem key={env.id} value={env.id}>{env.name}</SelectItem>)}</SelectContent>
</Select>}
{environmentError && <p role="alert" className="text-sm text-destructive">{environmentError}</p>}
{defaults}
<div className="flex justify-between gap-2"><Button variant="ghost" onClick={onCancel}>Cancel</Button><Button disabled={disabled || !name.trim() || !apiKey.trim() || save.isPending} onClick={() => save.mutate()}>{save.isPending ? "Connecting…" : "Connect"}</Button></div>
<ProviderApiKeyCard providerName={copilot ? "GitHub Copilot" : provider === "google" ? "Google" : "OpenRouter"} value={apiKey} onChange={setApiKey} onSubmit={() => { if (!preparing && !environmentError && name.trim() && apiKey.trim()) save.mutate(); }} disabled={disabled || save.isPending || preparing || Boolean(environmentError)} placeholder={copilot ? "github_pat_..." : "Enter API key here"} autoFocus />
<div className="flex justify-between gap-2"><Button variant="ghost" onClick={onCancel}>Cancel</Button><Button disabled={disabled || !name.trim() || !apiKey.trim() || save.isPending || preparing || Boolean(environmentError)} onClick={() => save.mutate()}>{save.isPending ? "Connecting…" : "Connect"}</Button></div>
</div>;
}
@@ -34,6 +34,7 @@ export function aiProviderForAdapter(
codex_local: "openai",
opencode_local: "openrouter",
grok_local: "xai",
copilot_runtime: "github",
gemini_local: "google",
hermes_local: "openrouter",
} as Record<string, AiProvider>
@@ -111,7 +112,7 @@ export function AiConnectionField({
};
const method: AiAuthMethod = (value && value.mode !== "router" && value.mode !== "responsible_user" ? value.method : undefined)
?? accounts.data?.connections.find((account) => account.provider === provider && account.isDefault)?.method
?? (provider === "openrouter" || provider === "google" ? "api_key" : "subscription");
?? (provider === "openrouter" || provider === "google" || provider === "github" ? "api_key" : "subscription");
const compatiblePools = agentId ? accounts.data?.pools?.filter(pool => pool.enabled && pool.members.some(member => isAiConnectionCompatible(member.binding, routerAdapterType ?? adapterType, member.profile.model, member.profile.provider, member.profile.acpxAgent))) ?? [] : [];
if (!provider) return null;
if (legacy && !value && !adopting)
@@ -236,7 +237,7 @@ export function AiConnectionField({
initialMethod={reconnecting?.method ?? method}
fixedMethod={Boolean(reconnecting)}
connectionId={reconnecting?.id}
name={reconnecting?.name ?? `My ${provider === "anthropic" ? "Claude" : provider === "openai" ? "OpenAI" : provider === "xai" ? "Grok" : provider === "google" ? "Gemini" : "OpenRouter"} ${method === "subscription" ? "subscription" : "API"}`}
name={reconnecting?.name ?? `My ${provider === "anthropic" ? "Claude" : provider === "openai" ? "OpenAI" : provider === "xai" ? "Grok" : provider === "google" ? "Gemini" : provider === "github" ? "GitHub Copilot" : "OpenRouter"} ${method === "subscription" ? "subscription" : "API"}`}
ownership="personal"
agentIds={agentId ? [agentId] : []}
allAgents={allAgents}
@@ -120,7 +120,7 @@ export function AiConnectionPicker({
})),
]}
onSelect={(id) => {
if (id === "responsible_user") onChange({provider: requirement.provider, method: personalDefault?.method ?? requirement.method ?? (requirement.provider === "openrouter" ? "api_key" : "subscription"), mode: "responsible_user"});
if (id === "responsible_user") onChange({provider: requirement.provider, method: personalDefault?.method ?? requirement.method ?? ((requirement.provider === "openrouter" || requirement.provider === "github") ? "api_key" : "subscription"), mode: "responsible_user"});
else { const connection = compatible.find((item) => item.id === id)!; select("shared", connection); }
}}
/>
@@ -63,7 +63,7 @@ const providers = [
advanced: true,
},
] as const;
type ProviderChoice = (typeof providers)[number]["id"];
type ProviderChoice = (typeof providers)[number]["id"] | "github";
export function AiProviderSetup({
companyId,
@@ -131,7 +131,7 @@ export function AiProviderSetup({
queryKey: ["agents", companyId, "provider-access"],
queryFn: () => agentsApi.list(companyId),
});
const label = providerLabel ?? providers.find((p) => p.id === provider)?.name ?? "provider";
const label = providerLabel ?? providers.find((p) => p.id === provider)?.name ?? (provider === "github" ? "GitHub Copilot" : "provider");
const advanced = reconnect ? Boolean(reconnect.routing) : ["openrouter", "bedrock", "gateway", "local"].includes(provider ?? "");
const nativeProvider: AiProvider =
provider === "bedrock"
+2 -1
View File
@@ -7,6 +7,7 @@ export const AI_PROVIDERS: Record<
AiProvider,
{ name: string; subscriptionName?: string; logo?: string }
> = {
github: { name: "GitHub Copilot", logo: "/brands/apps/github.svg" },
google: { name: "Google", logo: "/brands/apps/google.svg" },
anthropic: {
name: "Claude",
@@ -53,7 +54,7 @@ export function defaultAiConnectionName(ownerName: string | undefined, provider:
export function aiMethodLabel(provider: AiProvider, method: AiAuthMethod) {
return method === "subscription"
? (AI_PROVIDERS[provider].subscriptionName ?? "Subscription unavailable")
: "API key";
: provider === "github" ? "Personal access token" : "API key";
}
export function matchesAiRequirement(
@@ -34,6 +34,7 @@ import { projectsApi } from "../../api/projects";
import { useCompany } from "../../context/CompanyContext";
import { useSidebar } from "../../context/SidebarContext";
import { queryKeys } from "../../lib/queryKeys";
import { copilotTaskModelCatalogOptions } from "../../lib/copilot-task-model-catalog";
import { buildCompanyUserInlineOptions, buildCompanyUserLabelMap, buildCompanyUserProfileMap, isAgentTaskTarget } from "../../lib/company-members";
import { ISSUE_OVERRIDE_ADAPTER_TYPES, type IssueModelLane } from "../../lib/issue-assignee-overrides";
import { useProjectOrder } from "../../hooks/useProjectOrder";
@@ -733,11 +734,12 @@ export function IssueProperties({
const assignee = issue.assigneeAgentId
? agents?.find((a) => a.id === issue.assigneeAgentId)
: null;
const copilotCatalog = copilotTaskModelCatalogOptions(assignee);
const assigneeAdapterType = assignee?.adapterType ?? null;
const assigneeAdapterOverrides = issue.assigneeAdapterOverrides ?? null;
const showAssigneeAdapterOptions = assigneeAdapterOverrides !== null;
const supportsAssigneeOverrides = Boolean(
assigneeAdapterType && ISSUE_OVERRIDE_ADAPTER_TYPES.has(assigneeAdapterType),
assigneeAdapterType && (ISSUE_OVERRIDE_ADAPTER_TYPES.has(assigneeAdapterType) || copilotCatalog),
);
const assigneeOverrideLane = overrideLane(assigneeAdapterOverrides);
const assigneeOverrideAdapterConfig = asRecord(assigneeAdapterOverrides?.adapterConfig);
@@ -754,12 +756,16 @@ export function IssueProperties({
const assigneeOverrideChrome = assigneeAdapterType === "claude_local"
&& assigneeOverrideAdapterConfig.chrome === true;
const catalogProvider = assigneeAdapterType === "paperclip_runner" ? String(normalizeLegacyRunnerProvider(assigneePrimaryAdapterConfig).provider ?? "codex") : undefined;
const catalogEnvironmentId = copilotCatalog ? assignee?.defaultEnvironmentId ?? null : null;
const { data: assigneeAdapterModels } = useQuery({
queryKey:
companyId && assigneeAdapterType
? queryKeys.agents.adapterModels(companyId, assigneeAdapterType, null, catalogProvider)
? [...queryKeys.agents.adapterModels(companyId, assigneeAdapterType, catalogEnvironmentId, catalogProvider), copilotCatalog ?? null]
: ["agents", "none", "adapter-models", assigneeAdapterType ?? "none"],
queryFn: () => agentsApi.adapterModels(companyId!, assigneeAdapterType!, { provider: catalogProvider }),
queryFn: () => agentsApi.adapterModels(companyId!, assigneeAdapterType!, {
provider: catalogProvider,
...(copilotCatalog ? { environmentId: catalogEnvironmentId, ...copilotCatalog } : {}),
}),
enabled: Boolean(companyId) && showAssigneeAdapterOptions && supportsAssigneeOverrides,
});
const modelOverrideOptions = useMemo<InlineEntityOption[]>(() => {
@@ -224,6 +224,7 @@ export function AgentBasicsDialog({
<option value="codex">Codex (app server)</option>
<option value="claude">Claude (ACPX)</option>
<option value="grok">Grok Build (ACPX)</option>
<option value="copilot">GitHub Copilot</option>
<option value="opencode">OpenCode</option>
</select>
</label>
+29 -18
View File
@@ -114,7 +114,9 @@ function Setup({
const isRunner = adapterType === "paperclip_runner";
const isDot = isRunner && runnerProvider === "openai_dot";
const brandType = isDot ? "openai_dot" : isRunner
? runnerProvider === "grok"
? runnerProvider === "copilot"
? "copilot_runtime"
: runnerProvider === "grok"
? "grok_local"
: runnerProvider === "claude"
? "claude_local"
@@ -152,7 +154,9 @@ function Setup({
null,
);
const [runtimeAiBinding, setRuntimeAiBinding] = useState<AiConnectionBinding | undefined>(() =>
brandType === "opencode_local"
brandType === "copilot_runtime"
? { provider: "github", method: "api_key", mode: "responsible_user" }
: brandType === "opencode_local"
? { provider: "openrouter", method: "api_key", mode: "responsible_user" }
: undefined,
);
@@ -225,12 +229,6 @@ function Setup({
queryKey: queryKeys.environments.capabilities(companyId),
queryFn: () => environmentsApi.capabilities(companyId),
});
const models = useQuery({
queryKey: queryKeys.agents.adapterModels(companyId, brandType, null, aiBinding?.provider),
queryFn: () => agentsApi.adapterModels(companyId, brandType, { provider: aiBinding?.provider }),
enabled: Boolean(brandType) && showModel && !connectionModels,
retry: false,
});
const companySecrets = useQuery({
queryKey: queryKeys.secrets.list(companyId),
queryFn: () => secretsApi.list(companyId),
@@ -271,6 +269,14 @@ function Setup({
? cause.message
: "Could not resolve the environment.";
}
const models = useQuery({
queryKey: queryKeys.agents.adapterModels(companyId, brandType, environmentId, brandType === "copilot_runtime" ? JSON.stringify(aiBinding ?? null) : aiBinding?.provider),
queryFn: () => agentsApi.adapterModels(companyId, brandType === "copilot_runtime" ? "paperclip_runner" : brandType, brandType === "copilot_runtime"
? { provider: "acpx", acpxAgent: "copilot", aiConnection: aiBinding, environmentId }
: { provider: aiBinding?.provider, environmentId }),
enabled: Boolean(brandType) && showModel && !connectionModels && (brandType !== "copilot_runtime" || Boolean(aiBinding)),
retry: false,
});
const environment = envs.data?.find((env) => env.id === environmentId);
const sandboxProvider =
typeof environment?.config?.provider === "string"
@@ -355,9 +361,9 @@ function Setup({
...(isRunner
? {
adapterSchemaValues: {
provider: (runnerProvider === "claude" || runnerProvider === "grok") ? "acpx" : runnerProvider,
provider: (runnerProvider === "claude" || runnerProvider === "grok" || runnerProvider === "copilot") ? "acpx" : runnerProvider,
...(isDot ? { allowUnmeteredProvider } : {}),
...((runnerProvider === "claude" || runnerProvider === "grok") ? { acpxAgent: runnerProvider } : {}),
...((runnerProvider === "claude" || runnerProvider === "grok" || runnerProvider === "copilot") ? { acpxAgent: runnerProvider } : {}),
},
}
: {}),
@@ -365,8 +371,8 @@ function Setup({
const config = getUIAdapter(adapterType).buildAdapterConfig(values);
if (isRunner)
Object.assign(config, {
provider: (runnerProvider === "claude" || runnerProvider === "grok") ? "acpx" : runnerProvider,
...((runnerProvider === "claude" || runnerProvider === "grok") ? { acpxAgent: runnerProvider } : {}),
provider: (runnerProvider === "claude" || runnerProvider === "grok" || runnerProvider === "copilot") ? "acpx" : runnerProvider,
...((runnerProvider === "claude" || runnerProvider === "grok" || runnerProvider === "copilot") ? { acpxAgent: runnerProvider } : {}),
...(model ? { model } : {}),
});
if (!aiBinding && !nextConnection?.aiConnection && hasCredentialField && binding) {
@@ -397,6 +403,8 @@ function Setup({
return config;
}
function preparedConfig(nextConnection = connection) {
if (brandType === "copilot_runtime" && !models.data?.some(candidate => candidate.id === model))
throw new Error("Refresh Copilot models and select an available model before creating this agent.");
if (isDot && !allowUnmeteredProvider) throw new Error("Acknowledge external provider billing before creating your Dot agent.");
if (multiProvider && (!model.trim() || !model.includes("/")))
throw new Error("Choose or enter a model in provider/model format.");
@@ -858,7 +866,9 @@ function Setup({
</>}
{!connectionAdapter && aiProviderForAdapter(brandType) && (
<AiConnectionField companyId={companyId} agentName={name} adapterType={brandType} model={model} environmentId={environmentId ?? undefined} value={aiBinding}
onChange={binding => { binding.mode !== "router" && setRuntimeAiBinding(binding); resetTest(); }} />
onChange={binding => { binding.mode !== "router" && setRuntimeAiBinding(binding); resetTest();
if (brandType === "copilot_runtime") void cache.invalidateQueries({ queryKey: ["agents", companyId, "adapter-models", brandType] });
}} />
)}
{(connectionModels ? connectionModels.error : models.error) && <p role="alert" className="text-sm text-destructive">Could not load models. Retry or enter a model ID manually.</p>}
{((showModel && !usingKimiApi) ||
@@ -869,8 +879,8 @@ function Setup({
presentation="native"
models={connectionModels?.models ?? models.data ?? []}
loadingModels={connectionModels?.isLoading ?? models.isLoading}
onRefreshModels={connectionModels?.refreshModels}
refreshingModels={connectionModels?.refreshing}
onRefreshModels={connectionModels?.refreshModels ?? (brandType === "copilot_runtime" ? async () => { await models.refetch(); } : undefined)}
refreshingModels={connectionModels?.refreshing ?? (brandType === "copilot_runtime" ? models.isFetching : undefined)}
value={model}
onChange={(value) => {
setModel(connectionModels?.resolveModel(value) ?? value);
@@ -895,9 +905,9 @@ function Setup({
}}
open={modelOpen}
onOpenChange={setModelOpen}
allowDefault={!multiProvider}
required={multiProvider}
creatable
allowDefault={!multiProvider && brandType !== "copilot_runtime"}
required={multiProvider || brandType === "copilot_runtime"}
creatable={brandType !== "copilot_runtime"}
groupByProvider={multiProvider && !connectionModels}
preserveOrder={Boolean(connectionModels) || adapterCuratesModelOrder(brandType)}
/>
@@ -1162,6 +1172,7 @@ function Setup({
</fieldset>
<RuntimeTestCard
variant={isDot ? "prerequisites" : "connection"}
metadataOnly={brandType === "copilot_runtime"}
state={testState}
result={result}
error={error}
@@ -259,6 +259,26 @@ describe("composer assignee picker", () => {
expect(choices.some((item) => item.includes("GPT-6 Sol"))).toBe(false);
});
it("discovers Copilot task models with the assignee connection and environment", async () => {
const binding = { provider: "github", method: "api_key", mode: "shared", connectionId: "11111111-1111-4111-8111-111111111111", grantId: "33333333-3333-4333-8333-333333333333" } as const;
const copilot = { ...agent, adapterType: "paperclip_runner", defaultEnvironmentId: "daytona-1",
adapterConfig: { provider: "acpx", acpxAgent: "copilot", model: "gpt-5.6-luna" },
runtimeConfig: { aiConnection: binding } } as Agent;
const loadModels = vi.spyOn(agentsApi, "adapterModels").mockResolvedValueOnce([
{ id: "gpt-5.6-luna", label: "Copilot Luna" },
]);
render(vi.fn(), vi.fn(), true, { agents: new Map([[agent.id, copilot]]), settings: null });
await act(async () => { await new Promise((resolve) => setTimeout(resolve, 0)); });
expect(loadModels).toHaveBeenCalledWith("company-1", "paperclip_runner", {
environmentId: "daytona-1", provider: "acpx", poolId: undefined,
acpxAgent: "copilot", agentId: agent.id, aiConnection: binding,
});
await click("Select model and effort");
await click("Choose exact model");
expect(document.body.textContent).toContain("Copilot Luna");
expect(document.body.textContent).not.toContain("Claude Sonnet");
});
it("preserves settings when the selected assignee is chosen again", async () => {
const onAssigneeChange = vi.fn();
const onSettingsChange = vi.fn();
@@ -4,6 +4,7 @@ import { ArrowLeft, Check, ChevronDown, Plus, RotateCcw, Search, X, Zap } from "
import { aiRuntimeConnectionBindingSchema, type Agent, type IssueAssigneeAdapterOverrides } from "@paperclipai/shared";
import { agentsApi, type AdapterModel } from "@/api/agents";
import { queryKeys } from "@/lib/queryKeys";
import { copilotTaskModelCatalogOptions } from "@/lib/copilot-task-model-catalog";
import { cn } from "@/lib/utils";
import { useMobileEntityPickerViewportStyle } from "@/hooks/useMobileEntityPickerViewportStyle";
import { getLastComposerEffort, rememberComposerEffort } from "@/lib/recent-composer-effort";
@@ -106,12 +107,13 @@ export function ComposerRunSettingsPicker({
const provider = composerCatalogProvider(agent);
const binding = aiRuntimeConnectionBindingSchema.safeParse(agent?.runtimeConfig?.aiConnection).data;
const poolId = binding?.mode === "router" ? binding.connectionId : undefined;
const copilotCatalog = copilotTaskModelCatalogOptions(agent);
const { data: fetchedModels = [], isPending: modelsPending } = useQuery({
queryKey: agent && modelSupported
? [...queryKeys.agents.adapterModels(companyId, agent.adapterType, agent.defaultEnvironmentId ?? null, provider), poolId ?? null]
? [...queryKeys.agents.adapterModels(companyId, agent.adapterType, agent.defaultEnvironmentId ?? null, provider), poolId ?? null, copilotCatalog ?? null]
: ["agents", "composer-models", "none"],
queryFn: () => agentsApi.adapterModels(companyId, agent!.adapterType, {
environmentId: agent!.defaultEnvironmentId ?? null, provider, poolId,
environmentId: agent!.defaultEnvironmentId ?? null, provider, poolId, ...copilotCatalog,
}),
enabled: Boolean(agent && modelSupported && !modelOptionsOverride),
});