mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-28 12:04:41 +01:00
fix(dot): separate experimental import provider choices
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
5a1bdea4ca
commit
392d54f80c
2 files changed
+167
-29
No files matched your search
@@ -37,6 +37,11 @@ const mockAgentsApi = vi.hoisted(() => ({
|
||||
const mockAdaptersApi = vi.hoisted(() => ({
|
||||
list: vi.fn(),
|
||||
}));
|
||||
const mockInstanceSettingsApi = vi.hoisted(() => ({
|
||||
getExperimental: vi.fn(),
|
||||
getGeneral: vi.fn(),
|
||||
get: vi.fn(),
|
||||
}));
|
||||
const mockRoutinesApi = vi.hoisted(() => ({
|
||||
update: vi.fn(),
|
||||
}));
|
||||
@@ -73,6 +78,10 @@ vi.mock("../api/adapters", () => ({
|
||||
adaptersApi: mockAdaptersApi,
|
||||
}));
|
||||
|
||||
vi.mock("../api/instanceSettings", () => ({
|
||||
instanceSettingsApi: mockInstanceSettingsApi,
|
||||
}));
|
||||
|
||||
vi.mock("../api/routines", () => ({
|
||||
routinesApi: mockRoutinesApi,
|
||||
}));
|
||||
@@ -268,6 +277,9 @@ describe("CompanyImport", () => {
|
||||
document.body.appendChild(container);
|
||||
mockAuthApi.getSession.mockResolvedValue({ user: { id: "user-1" } });
|
||||
mockAgentsApi.list.mockResolvedValue([]);
|
||||
mockInstanceSettingsApi.getExperimental.mockResolvedValue({ enableNativeRunner: false, enableOpenAiDot: false });
|
||||
mockInstanceSettingsApi.getGeneral.mockResolvedValue({});
|
||||
mockInstanceSettingsApi.get.mockResolvedValue({});
|
||||
mockAdaptersApi.list.mockResolvedValue([
|
||||
{ type: "claude_local", disabled: false },
|
||||
{ type: "codex_local", disabled: false },
|
||||
@@ -1059,7 +1071,7 @@ describe("CompanyImport", () => {
|
||||
function lastImportMeta() {
|
||||
const call = mockCompaniesApi.importBundleAsync.mock.calls.at(-1);
|
||||
expect(call).toBeTruthy();
|
||||
return call![0] as { adapterOverrides?: Record<string, { adapterType: string }> };
|
||||
return call![0] as { adapterOverrides?: Record<string, { adapterType: string; adapterConfig?: Record<string, unknown> }> };
|
||||
}
|
||||
|
||||
it("keeps manifest adapters and sends no overrides when the user touches nothing", async () => {
|
||||
@@ -1111,6 +1123,7 @@ describe("CompanyImport", () => {
|
||||
});
|
||||
|
||||
it("offers Paperclip Runner import configuration after its experimental flag is enabled", async () => {
|
||||
mockInstanceSettingsApi.getExperimental.mockResolvedValue({ enableNativeRunner: true, enableOpenAiDot: false });
|
||||
mockAdaptersApi.list.mockResolvedValue([
|
||||
{ type: "claude_local", disabled: false },
|
||||
{ type: "codex_local", disabled: false },
|
||||
@@ -1124,6 +1137,82 @@ describe("CompanyImport", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps imported Dot agents and falls back other Runner providers under Dot-only opt-in", async () => {
|
||||
mockInstanceSettingsApi.getExperimental.mockResolvedValue({ enableNativeRunner: false, enableOpenAiDot: true });
|
||||
mockAdaptersApi.list.mockResolvedValue([
|
||||
{ type: "claude_local", disabled: false },
|
||||
{ type: "paperclip_runner", disabled: false },
|
||||
]);
|
||||
const preview = buildMixedAdapterPreviewResult();
|
||||
preview.manifest.agents[0]!.adapterType = "paperclip_runner";
|
||||
preview.manifest.agents[0]!.adapterConfig = { provider: "openai_dot", allowUnmeteredProvider: true };
|
||||
preview.manifest.agents[1]!.adapterType = "paperclip_runner";
|
||||
preview.manifest.agents[1]!.adapterConfig = { provider: "codex" };
|
||||
mockCompaniesApi.importPreview.mockResolvedValue(preview);
|
||||
await renderPage();
|
||||
await enterGithubUrl();
|
||||
await clickButton((text) => text === "Preview import");
|
||||
|
||||
expect(findAdapterSelects().map((select) => select.value)).toEqual(["openai_dot", "claude_local"]);
|
||||
for (const select of findAdapterSelects()) {
|
||||
const choices = Array.from(select.options).map((option) => option.value);
|
||||
expect(choices).toContain("openai_dot");
|
||||
expect(choices).not.toContain("paperclip_runner");
|
||||
}
|
||||
await clickButton((text) => text.startsWith("Import 3 file"));
|
||||
await settle();
|
||||
expect(lastImportMeta().adapterOverrides).toEqual({ researcher: { adapterType: "claude_local" } });
|
||||
});
|
||||
|
||||
it("requires billing acknowledgement and serializes the standalone Dot import choice as Runner", async () => {
|
||||
mockInstanceSettingsApi.getExperimental.mockResolvedValue({ enableNativeRunner: false, enableOpenAiDot: true });
|
||||
mockAdaptersApi.list.mockResolvedValue([
|
||||
{ type: "claude_local", disabled: false },
|
||||
{ type: "paperclip_runner", disabled: false },
|
||||
]);
|
||||
await previewMixedAdapterPackage();
|
||||
const select = findAdapterSelects()[0]!;
|
||||
await act(async () => {
|
||||
const setter = Object.getOwnPropertyDescriptor(HTMLSelectElement.prototype, "value")!.set!;
|
||||
setter.call(select, "openai_dot");
|
||||
select.dispatchEvent(new Event("change", { bubbles: true }));
|
||||
});
|
||||
await settle();
|
||||
expect(container.textContent).toContain("Allow externally billed provider");
|
||||
expect(container.textContent).not.toContain("Harness");
|
||||
await clickButton((text) => text.startsWith("Import 3 file"));
|
||||
await settle();
|
||||
expect(mockCompaniesApi.importBundleAsync).not.toHaveBeenCalled();
|
||||
expect(container.textContent).toContain("OpenAI Dot requires acknowledgement of external billing");
|
||||
|
||||
const billingToggle = Array.from(container.querySelectorAll<HTMLButtonElement>('button[role="switch"]'))
|
||||
.find((button) => button.closest("label")?.textContent?.includes("Allow externally billed provider")
|
||||
|| button.parentElement?.textContent?.includes("Allow externally billed provider"));
|
||||
expect(billingToggle).toBeTruthy();
|
||||
await act(async () => billingToggle!.click());
|
||||
await flushReact();
|
||||
await clickButton((text) => text.startsWith("Import 3 file"));
|
||||
await settle();
|
||||
expect(lastImportMeta().adapterOverrides?.coder).toEqual({
|
||||
adapterType: "paperclip_runner",
|
||||
adapterConfig: {
|
||||
provider: "openai_dot", lifecycleMode: "per_turn", allowUnmeteredProvider: true,
|
||||
dotAttachmentAccess: false, dotWorkspaceAccess: false,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("does not expose Dot when only the general Runner option is enabled", async () => {
|
||||
mockInstanceSettingsApi.getExperimental.mockResolvedValue({ enableNativeRunner: true, enableOpenAiDot: false });
|
||||
mockAdaptersApi.list.mockResolvedValue([{ type: "paperclip_runner", disabled: false }]);
|
||||
await previewMixedAdapterPackage();
|
||||
for (const select of findAdapterSelects()) {
|
||||
const choices = Array.from(select.options).map((option) => option.value);
|
||||
expect(choices).toContain("paperclip_runner");
|
||||
expect(choices).not.toContain("openai_dot");
|
||||
}
|
||||
});
|
||||
|
||||
it("falls back to the CEO adapter with a visible warning when a manifest adapter is not installed", async () => {
|
||||
// The destination has no codex_local adapter; the CEO fallback (an empty
|
||||
// agent list defaults to claude_local) takes over — never silently.
|
||||
|
||||
@@ -15,6 +15,7 @@ import { authApi } from "../api/auth";
|
||||
import { ApiError } from "../api/client";
|
||||
import { companiesApi, type CompanyImportJobAccepted } from "../api/companies";
|
||||
import { adaptersApi } from "../api/adapters";
|
||||
import { instanceSettingsApi } from "../api/instanceSettings";
|
||||
import { agentsApi } from "../api/agents";
|
||||
import { routinesApi } from "../api/routines";
|
||||
import { sidebarPreferencesApi } from "../api/sidebarPreferences";
|
||||
@@ -557,10 +558,29 @@ function ConflictResolutionList({
|
||||
// ── Adapter type options for import ───────────────────────────────────
|
||||
|
||||
const FALLBACK_IMPORT_ADAPTER_TYPE = "claude_local";
|
||||
const IMPORT_ADAPTER_OPTIONS: { value: string; label: string }[] = listUIAdapters().map((adapter) => ({
|
||||
value: adapter.type,
|
||||
label: adapterLabels[adapter.type] ?? getAdapterLabel(adapter.type),
|
||||
}));
|
||||
const IMPORT_ADAPTER_OPTIONS: { value: string; label: string }[] = [
|
||||
...listUIAdapters().map((adapter) => ({
|
||||
value: adapter.type,
|
||||
label: adapterLabels[adapter.type] ?? getAdapterLabel(adapter.type),
|
||||
})),
|
||||
{ value: "openai_dot", label: "OpenAI Dot (experimental)" },
|
||||
];
|
||||
|
||||
// Dot is a picker choice backed by the shared Runner API adapter.
|
||||
function importAdapterValues(choice: string, sourceConfig?: Record<string, unknown>): CreateConfigValues {
|
||||
return {
|
||||
...defaultCreateValues,
|
||||
adapterType: choice === "openai_dot" ? "paperclip_runner" : choice,
|
||||
...(choice === "openai_dot" ? {
|
||||
adapterSchemaValues: {
|
||||
provider: "openai_dot",
|
||||
allowUnmeteredProvider: sourceConfig?.allowUnmeteredProvider === true,
|
||||
dotAttachmentAccess: sourceConfig?.dotAttachmentAccess === true,
|
||||
dotWorkspaceAccess: sourceConfig?.dotWorkspaceAccess === true,
|
||||
},
|
||||
} : {}),
|
||||
};
|
||||
}
|
||||
|
||||
// ── Adapter picker for imported agents ───────────────────────────────
|
||||
|
||||
@@ -569,11 +589,14 @@ interface AdapterPickerItem {
|
||||
name: string;
|
||||
/** Adapter type from the package manifest (the source's adapter). */
|
||||
adapterType: string;
|
||||
/** Display choice, including the separate Dot provider. */
|
||||
adapterChoice: string;
|
||||
adapterConfig: Record<string, unknown>;
|
||||
/**
|
||||
* Set when the manifest adapter is not installed on the destination: the
|
||||
* adapter type the agent falls back to unless the user picks another one.
|
||||
* Null when the manifest adapter is usable here (or availability is unknown,
|
||||
* which fails open to the manifest adapter except for native runner).
|
||||
* which fails open to the manifest adapter except for experimental Runner providers).
|
||||
*/
|
||||
fallbackAdapterType: string | null;
|
||||
}
|
||||
@@ -611,9 +634,11 @@ function AdapterPickerList({
|
||||
<div className="divide-y divide-border">
|
||||
{agents.map((agent) => {
|
||||
const selectedType =
|
||||
adapterOverrides[agent.slug] ?? agent.fallbackAdapterType ?? agent.adapterType;
|
||||
adapterOverrides[agent.slug] ?? agent.fallbackAdapterType ?? agent.adapterChoice;
|
||||
const isExpanded = expandedSlugs.has(agent.slug);
|
||||
const vals = configValues[agent.slug] ?? { ...defaultCreateValues, adapterType: selectedType };
|
||||
const vals = configValues[agent.slug] ?? importAdapterValues(
|
||||
selectedType, selectedType === agent.adapterChoice ? agent.adapterConfig : undefined,
|
||||
);
|
||||
|
||||
return (
|
||||
<div key={agent.slug}>
|
||||
@@ -656,7 +681,7 @@ function AdapterPickerList({
|
||||
{agent.fallbackAdapterType && (
|
||||
<div className="mx-4 mb-2.5 rounded-md border border-amber-500/30 bg-amber-500/5 px-3 py-2">
|
||||
<p className="text-xs text-amber-500">
|
||||
source adapter {agent.adapterType} is not installed here — this agent
|
||||
source adapter {agent.adapterChoice === "openai_dot" ? "OpenAI Dot" : agent.adapterType} is not installed here — this agent
|
||||
will use {adapterLabels[selectedType] ?? getAdapterLabel(selectedType)}
|
||||
</p>
|
||||
</div>
|
||||
@@ -1028,17 +1053,22 @@ export function CompanyImport() {
|
||||
if (!installedAdapters) return null;
|
||||
return new Set(installedAdapters.filter((a) => !a.disabled).map((a) => a.type));
|
||||
}, [installedAdapters]);
|
||||
// Native runner is the one adapter that fails closed in the importer. Other
|
||||
// adapter choices preserve the importer's existing fail-open behavior when
|
||||
// availability cannot be read, but Paperclip Runner only appears after the
|
||||
// server explicitly reports that its experimental flag is enabled.
|
||||
const nativeRunnerAvailable =
|
||||
availableAdapterTypes?.has("paperclip_runner") === true;
|
||||
const { data: experimentalSettings } = useQuery({
|
||||
queryKey: queryKeys.instance.experimentalSettings,
|
||||
queryFn: () => instanceSettingsApi.getExperimental(),
|
||||
retry: false,
|
||||
});
|
||||
// Each Runner provider choice fails closed until its own opt-in and the
|
||||
// shared adapter's availability are known. Dot does not enable other providers.
|
||||
const runnerInstalled = availableAdapterTypes?.has("paperclip_runner") === true;
|
||||
const nativeRunnerAvailable = runnerInstalled && experimentalSettings?.enableNativeRunner === true;
|
||||
const dotAvailable = runnerInstalled && experimentalSettings?.enableOpenAiDot === true;
|
||||
const importAdapterOptions = useMemo(
|
||||
() => IMPORT_ADAPTER_OPTIONS.filter(
|
||||
(option) => option.value !== "paperclip_runner" || nativeRunnerAvailable,
|
||||
() => IMPORT_ADAPTER_OPTIONS.filter((option) =>
|
||||
option.value === "paperclip_runner" ? nativeRunnerAvailable
|
||||
: option.value === "openai_dot" ? dotAvailable : true,
|
||||
),
|
||||
[nativeRunnerAvailable],
|
||||
[nativeRunnerAvailable, dotAvailable],
|
||||
);
|
||||
|
||||
const localZipHelpText =
|
||||
@@ -1519,9 +1549,16 @@ export function CompanyImport() {
|
||||
// Reset config values when adapter type changes
|
||||
setAdapterConfigValues((prev) => {
|
||||
const next = { ...prev };
|
||||
delete next[slug];
|
||||
if (adapterType === "openai_dot" || adapterType === "paperclip_runner") {
|
||||
next[slug] = importAdapterValues(adapterType);
|
||||
} else {
|
||||
delete next[slug];
|
||||
}
|
||||
return next;
|
||||
});
|
||||
if (adapterType === "openai_dot") {
|
||||
setAdapterExpandedSlugs((prev) => new Set([...prev, slug]));
|
||||
}
|
||||
}
|
||||
|
||||
function handleAdapterToggleExpand(slug: string) {
|
||||
@@ -1539,7 +1576,9 @@ export function CompanyImport() {
|
||||
const currentType = agent ? effectiveAdapterType(agent) : adapterOverrides[slug] ?? "claude_local";
|
||||
setAdapterConfigValues((prev) => ({
|
||||
...prev,
|
||||
[slug]: { ...(prev[slug] ?? { ...defaultCreateValues, adapterType: currentType }), ...patch },
|
||||
[slug]: { ...(prev[slug] ?? importAdapterValues(
|
||||
currentType, currentType === agent?.adapterChoice ? agent.adapterConfig : undefined,
|
||||
)), ...patch },
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -1589,8 +1628,10 @@ export function CompanyImport() {
|
||||
const adapterAgents = useMemo<AdapterPickerItem[]>(() => {
|
||||
if (!importPreview) return [];
|
||||
return importPreview.manifest.agents.map((a) => {
|
||||
const adapterChoice = a.adapterType === "paperclip_runner" && a.adapterConfig?.provider === "openai_dot"
|
||||
? "openai_dot" : a.adapterType;
|
||||
let fallbackAdapterType: string | null = null;
|
||||
if (a.adapterType === "paperclip_runner" && !nativeRunnerAvailable) {
|
||||
if (a.adapterType === "paperclip_runner" && !(adapterChoice === "openai_dot" ? dotAvailable : nativeRunnerAvailable)) {
|
||||
const firstEnabledLegacyAdapter = availableAdapterTypes
|
||||
? [...availableAdapterTypes].find((type) => type !== "paperclip_runner") ?? null
|
||||
: null;
|
||||
@@ -1603,23 +1644,28 @@ export function CompanyImport() {
|
||||
// The fallback must itself be installed: the CEO's adapter when it is,
|
||||
// else any installed adapter, else null so the manifest adapter stands
|
||||
// and the server's unknown-adapter rejection is the backstop.
|
||||
fallbackAdapterType = availableAdapterTypes.has(ceoAdapterType)
|
||||
const selectableTypes = [...availableAdapterTypes].filter((type) =>
|
||||
type !== "paperclip_runner" || nativeRunnerAvailable,
|
||||
);
|
||||
fallbackAdapterType = selectableTypes.includes(ceoAdapterType)
|
||||
? ceoAdapterType
|
||||
: [...availableAdapterTypes][0] ?? null;
|
||||
: selectableTypes[0] ?? null;
|
||||
}
|
||||
|
||||
return {
|
||||
slug: a.slug,
|
||||
name: a.name,
|
||||
adapterType: a.adapterType,
|
||||
adapterChoice,
|
||||
adapterConfig: a.adapterConfig ?? {},
|
||||
fallbackAdapterType,
|
||||
};
|
||||
});
|
||||
}, [importPreview, availableAdapterTypes, ceoAdapterType, nativeRunnerAvailable]);
|
||||
}, [importPreview, availableAdapterTypes, ceoAdapterType, nativeRunnerAvailable, dotAvailable]);
|
||||
|
||||
/** The adapter type an imported agent will actually use: an explicit user pick, else the availability fallback, else the manifest adapter. */
|
||||
function effectiveAdapterType(agent: AdapterPickerItem): string {
|
||||
return adapterOverrides[agent.slug] ?? agent.fallbackAdapterType ?? agent.adapterType;
|
||||
return adapterOverrides[agent.slug] ?? agent.fallbackAdapterType ?? agent.adapterChoice;
|
||||
}
|
||||
|
||||
// Build final adapterOverrides for import request. Only agents that diverge
|
||||
@@ -1631,11 +1677,14 @@ export function CompanyImport() {
|
||||
for (const agent of adapterAgents) {
|
||||
const selectedType = effectiveAdapterType(agent);
|
||||
const configVals = adapterConfigValues[agent.slug];
|
||||
if (selectedType === agent.adapterType && !configVals) continue;
|
||||
const override: CompanyPortabilityAdapterOverride = { adapterType: selectedType };
|
||||
if (selectedType === agent.adapterChoice && !configVals) continue;
|
||||
const apiType = selectedType === "openai_dot" ? "paperclip_runner" : selectedType;
|
||||
const override: CompanyPortabilityAdapterOverride = { adapterType: apiType };
|
||||
if (configVals) {
|
||||
const uiAdapter = getUIAdapter(selectedType);
|
||||
override.adapterConfig = uiAdapter.buildAdapterConfig(configVals);
|
||||
if (selectedType === "openai_dot" && configVals.adapterSchemaValues?.allowUnmeteredProvider !== true) {
|
||||
throw new Error("OpenAI Dot requires acknowledgement of external billing. Configure the Dot adapter and enable Allow externally billed provider.");
|
||||
}
|
||||
override.adapterConfig = getUIAdapter(apiType).buildAdapterConfig(configVals);
|
||||
}
|
||||
overrides[agent.slug] = override;
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user