diff --git a/ui/src/pages/CompanyImport.test.tsx b/ui/src/pages/CompanyImport.test.tsx index 310f16203c2..b27910aa2b6 100644 --- a/ui/src/pages/CompanyImport.test.tsx +++ b/ui/src/pages/CompanyImport.test.tsx @@ -22,6 +22,9 @@ const mockAgentsApi = vi.hoisted(() => ({ list: vi.fn(), resume: vi.fn(), })); +const mockAdaptersApi = vi.hoisted(() => ({ + list: vi.fn(), +})); const mockRoutinesApi = vi.hoisted(() => ({ update: vi.fn(), })); @@ -54,6 +57,10 @@ vi.mock("../api/agents", () => ({ agentsApi: mockAgentsApi, })); +vi.mock("../api/adapters", () => ({ + adaptersApi: mockAdaptersApi, +})); + vi.mock("../api/routines", () => ({ routinesApi: mockRoutinesApi, })); @@ -156,6 +163,44 @@ function buildPreviewResult(): CompanyPortabilityPreviewResult { } as unknown as CompanyPortabilityPreviewResult; } +/** Preview whose manifest mixes adapters: one claude_local agent and one codex_local agent. */ +function buildMixedAdapterPreviewResult(): CompanyPortabilityPreviewResult { + return { + include: { company: true, agents: true, projects: true, issues: true }, + targetCompanyId: null, + targetCompanyName: null, + collisionStrategy: "rename", + selectedAgentSlugs: ["coder", "researcher"], + plan: { + companyAction: "create", + agentPlans: [ + { slug: "coder", action: "create", plannedName: "Coder", existingAgentId: null, reason: null }, + { slug: "researcher", action: "create", plannedName: "Researcher", existingAgentId: null, reason: null }, + ], + projectPlans: [], + issuePlans: [], + }, + manifest: { + agents: [ + { slug: "coder", name: "Coder", path: "agents/coder/AGENTS.md", adapterType: "claude_local" }, + { slug: "researcher", name: "Researcher", path: "agents/researcher/AGENTS.md", adapterType: "codex_local" }, + ], + projects: [], + issues: [], + skills: [], + company: null, + }, + files: { + ".paperclip.yaml": 'schema: "paperclip/v1"\n', + "agents/coder/AGENTS.md": "---\nname: Coder\n---\n\nYou write code.\n", + "agents/researcher/AGENTS.md": "---\nname: Researcher\n---\n\nYou research.\n", + }, + envInputs: [], + warnings: [], + errors: [], + } as unknown as CompanyPortabilityPreviewResult; +} + function buildImportResult(): CompanyPortabilityImportResult { return { company: { id: "company-2", name: "Imported Test", action: "created" }, @@ -186,6 +231,10 @@ describe("CompanyImport", () => { document.body.appendChild(container); mockAuthApi.getSession.mockResolvedValue({ user: { id: "user-1" } }); mockAgentsApi.list.mockResolvedValue([]); + mockAdaptersApi.list.mockResolvedValue([ + { type: "claude_local", disabled: false }, + { type: "codex_local", disabled: false }, + ]); mockAgentsApi.resume.mockResolvedValue({ id: "agent-1", status: "idle" }); mockRoutinesApi.update.mockResolvedValue({ id: "routine-1", status: "active" }); mockCompaniesApi.importPreview.mockResolvedValue(buildPreviewResult()); @@ -775,4 +824,111 @@ describe("CompanyImport", () => { // The stored entry is cleared once the job settles. expect(sessionStorage.getItem("paperclip:company-import-job:company-1:acme/starter")).toBeNull(); }); + + /** Adapter selects in the picker list, in manifest order (excludes the target/collision selects). */ + function findAdapterSelects() { + return Array.from(container.querySelectorAll("select")) + .filter((select) => select.value !== "new" && select.value !== "existing" && select.value !== "rename"); + } + + async function previewMixedAdapterPackage() { + mockCompaniesApi.importPreview.mockResolvedValue(buildMixedAdapterPreviewResult()); + await renderPage(); + await enterGithubUrl(); + await clickButton((text) => text === "Preview import"); + } + + function lastImportMeta() { + const call = mockCompaniesApi.importBundleAsync.mock.calls.at(-1); + expect(call).toBeTruthy(); + return call![0] as { adapterOverrides?: Record }; + } + + it("keeps manifest adapters and sends no overrides when the user touches nothing", async () => { + await previewMixedAdapterPackage(); + + // The picker shows each agent's source adapter, not a coerced CEO default. + expect(findAdapterSelects().map((select) => select.value)).toEqual(["claude_local", "codex_local"]); + + await clickButton((text) => text.startsWith("Import 3 file")); + await settle(); + + // Untouched agents flow through with no override at all, so the server + // applies each agent's manifest adapterType. + expect(lastImportMeta().adapterOverrides).toBeUndefined(); + }); + + it("sends an override only for the agent whose adapter the user changed", async () => { + await previewMixedAdapterPackage(); + + const coderSelect = findAdapterSelects()[0]; + expect(coderSelect?.value).toBe("claude_local"); + await act(async () => { + const setter = Object.getOwnPropertyDescriptor(HTMLSelectElement.prototype, "value")!.set!; + setter.call(coderSelect!, "codex_local"); + coderSelect!.dispatchEvent(new Event("change", { bubbles: true })); + }); + await flushReact(); + + await clickButton((text) => text.startsWith("Import 3 file")); + await settle(); + + expect(lastImportMeta().adapterOverrides).toEqual({ + coder: { adapterType: "codex_local" }, + }); + }); + + 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. + mockAdaptersApi.list.mockResolvedValue([{ type: "claude_local", disabled: false }]); + await previewMixedAdapterPackage(); + + expect(container.textContent).toContain("source adapter codex_local is not installed here"); + expect(container.textContent).toContain("will use Claude Code"); + expect(findAdapterSelects().map((select) => select.value)).toEqual(["claude_local", "claude_local"]); + + await clickButton((text) => text.startsWith("Import 3 file")); + await settle(); + + // Only the unavailable agent is overridden; the claude_local agent still + // carries no override and keeps its manifest adapter. + expect(lastImportMeta().adapterOverrides).toEqual({ + researcher: { adapterType: "claude_local" }, + }); + }); + + it("picks an installed adapter as the fallback when the CEO adapter is itself unavailable", async () => { + // Neither codex_local nor the CEO fallback (claude_local) is installed; + // the fallback must be an adapter that actually exists on the + // destination, never an unavailable type the server would reject. + mockAdaptersApi.list.mockResolvedValue([{ type: "gemini_local", disabled: false }]); + await previewMixedAdapterPackage(); + + expect(container.textContent).toContain("source adapter codex_local is not installed here"); + expect(findAdapterSelects().map((select) => select.value)).toEqual(["gemini_local", "gemini_local"]); + + await clickButton((text) => text.startsWith("Import 3 file")); + await settle(); + + expect(lastImportMeta().adapterOverrides).toEqual({ + researcher: { adapterType: "gemini_local" }, + coder: { adapterType: "gemini_local" }, + }); + }); + + it("fails open to manifest adapters when the adapter list cannot be read", async () => { + // Unknown availability must never coerce: with no adapter list, every + // agent keeps its manifest adapter and no fallback warning renders. + mockAdaptersApi.list.mockRejectedValue(new ApiError("adapters unavailable", 500, null)); + await previewMixedAdapterPackage(); + + expect(container.textContent).not.toContain("is not installed here"); + expect(findAdapterSelects().map((select) => select.value)).toEqual(["claude_local", "codex_local"]); + + await clickButton((text) => text.startsWith("Import 3 file")); + await settle(); + + expect(lastImportMeta().adapterOverrides).toBeUndefined(); + }); }); diff --git a/ui/src/pages/CompanyImport.tsx b/ui/src/pages/CompanyImport.tsx index a227894a68c..966b6863ea5 100644 --- a/ui/src/pages/CompanyImport.tsx +++ b/ui/src/pages/CompanyImport.tsx @@ -14,6 +14,7 @@ import { useToastActions } from "../context/ToastContext"; import { authApi } from "../api/auth"; import { ApiError } from "../api/client"; import { companiesApi, type CompanyImportJobAccepted } from "../api/companies"; +import { adaptersApi } from "../api/adapters"; import { agentsApi } from "../api/agents"; import { routinesApi } from "../api/routines"; import { sidebarPreferencesApi } from "../api/sidebarPreferences"; @@ -559,7 +560,15 @@ const IMPORT_ADAPTER_OPTIONS: { value: string; label: string }[] = listUIAdapter interface AdapterPickerItem { slug: string; name: string; + /** Adapter type from the package manifest (the source's adapter). */ adapterType: string; + /** + * 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). + */ + fallbackAdapterType: string | null; } function AdapterPickerList({ @@ -592,7 +601,8 @@ function AdapterPickerList({
{agents.map((agent) => { - const selectedType = adapterOverrides[agent.slug] ?? agent.adapterType; + const selectedType = + adapterOverrides[agent.slug] ?? agent.fallbackAdapterType ?? agent.adapterType; const isExpanded = expandedSlugs.has(agent.slug); const vals = configValues[agent.slug] ?? { ...defaultCreateValues, adapterType: selectedType }; @@ -634,6 +644,14 @@ function AdapterPickerList({ configure adapter
+ {agent.fallbackAdapterType && ( +
+

+ source adapter {agent.adapterType} is not installed here — this agent + will use {adapterLabels[selectedType] ?? getAdapterLabel(selectedType)} +

+
+ )} {isExpanded && (
adaptersApi.list(), + staleTime: 5 * 60 * 1000, + }); + // Null while the list is loading or unreadable: availability is unknown, so + // fail open and trust the manifest rather than coercing every agent. + const availableAdapterTypes = useMemo(() => { + if (!installedAdapters) return null; + return new Set(installedAdapters.filter((a) => !a.disabled).map((a) => a.type)); + }, [installedAdapters]); + const localZipHelpText = "Upload a .zip exported directly from Paperclip. Re-zipped archives created by Finder, Explorer, or other zip tools may not import correctly."; @@ -952,12 +986,10 @@ export function CompanyImport() { setSkippedSlugs(new Set()); setConfirmedSlugs(new Set()); - // Initialize adapter overrides — default all agents to the CEO's adapter type - const defaultAdapters: Record = {}; - for (const agent of result.manifest.agents) { - defaultAdapters[agent.slug] = ceoAdapterType; - } - setAdapterOverrides(defaultAdapters); + // Adapter overrides start empty: each agent keeps its manifest adapter + // unless the user changes it, or the manifest adapter is not installed + // here (handled per-agent via a warned fallback, never seeded silently). + setAdapterOverrides({}); setAdapterExpandedSlugs(new Set()); setAdapterConfigValues({}); @@ -1346,9 +1378,11 @@ export function CompanyImport() { function handleAdapterConfigChange(slug: string, patch: Partial) { resetMutationState(); + const agent = adapterAgents.find((a) => a.slug === slug); + const currentType = agent ? effectiveAdapterType(agent) : adapterOverrides[slug] ?? "claude_local"; setAdapterConfigValues((prev) => ({ ...prev, - [slug]: { ...(prev[slug] ?? { ...defaultCreateValues, adapterType: adapterOverrides[slug] ?? "claude_local" }), ...patch }, + [slug]: { ...(prev[slug] ?? { ...defaultCreateValues, adapterType: currentType }), ...patch }, })); } @@ -1392,23 +1426,42 @@ export function CompanyImport() { } } - // Build the list of agents for adapter picking + // Build the list of agents for adapter picking. An agent whose manifest + // adapter is not installed on the destination gets a warned fallback to the + // CEO's adapter; while availability is unknown the manifest adapter stands. const adapterAgents = useMemo(() => { if (!importPreview) return []; return importPreview.manifest.agents.map((a) => ({ slug: a.slug, name: a.name, adapterType: a.adapterType, + // 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 && !availableAdapterTypes.has(a.adapterType) + ? availableAdapterTypes.has(ceoAdapterType) + ? ceoAdapterType + : [...availableAdapterTypes][0] ?? null + : null, })); - }, [importPreview]); + }, [importPreview, availableAdapterTypes, ceoAdapterType]); - // Build final adapterOverrides for import request + /** 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; + } + + // Build final adapterOverrides for import request. Only agents that diverge + // from the manifest adapter (a user pick or an availability fallback) or + // carry edited adapter config send an override — untouched agents flow + // through with none, so the manifest adapter survives the import. function buildFinalAdapterOverrides(): Record | undefined { - if (adapterAgents.length === 0) return undefined; const overrides: Record = {}; for (const agent of adapterAgents) { - const selectedType = adapterOverrides[agent.slug] ?? agent.adapterType; + const selectedType = effectiveAdapterType(agent); const configVals = adapterConfigValues[agent.slug]; + if (selectedType === agent.adapterType && !configVals) continue; const override: CompanyPortabilityAdapterOverride = { adapterType: selectedType }; if (configVals) { const uiAdapter = getUIAdapter(selectedType);