fix(dot): separate experimental import provider choices

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-10-07 18:02:45 -05:00
1 parent 5a1bdea4ca
commit 392d54f80c
2 files changed
+167 -29

No files matched your search

+90 -1
View File
@@ -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.
+77 -28
View File
@@ -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;
}