mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-11 13:24:55 +01:00
fix(dot): allow independent Dot selection on agent routes
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
72de4984a9
commit
3168b2c0ca
5 files changed
+72
-17
No files matched your search
@@ -67,7 +67,7 @@ const mockApprovalService = vi.hoisted(() => ({
|
||||
const mockInstanceSettingsService = vi.hoisted(() => ({
|
||||
get: vi.fn(async () => ({ defaultEnvironmentId: null })),
|
||||
getGeneral: vi.fn(async () => ({ censorUsernameInLogs: false })),
|
||||
getExperimental: vi.fn(async () => ({ enableNativeRunner: false })),
|
||||
getExperimental: vi.fn(async (): Promise<{ enableNativeRunner: boolean; enableOpenAiDot?: boolean }> => ({ enableNativeRunner: false })),
|
||||
}));
|
||||
|
||||
const mockManagedAgentProfileService = vi.hoisted(() => ({
|
||||
@@ -673,6 +673,7 @@ describe("agent routes adapter validation", () => {
|
||||
});
|
||||
|
||||
it("rejects a new paperclip_runner selection while the rollout flag is off", async () => {
|
||||
mockInstanceSettingsService.getExperimental.mockResolvedValue({ enableNativeRunner: false, enableOpenAiDot: true });
|
||||
const app = await createApp();
|
||||
const res = await requestApp(app, (baseUrl) =>
|
||||
request(baseUrl)
|
||||
@@ -737,19 +738,45 @@ describe("agent routes adapter validation", () => {
|
||||
expect(mockAgentService.update).toHaveBeenCalledWith(expect.any(String), expect.objectContaining({ adapterConfig: expect.objectContaining({ dotAttachmentAccess: true, dotWorkspaceAccess: false }) }), expect.anything());
|
||||
});
|
||||
|
||||
it.each(["create", "convert"])("saves an unpaired Dot configuration for %s while refusing task admission", async mode => {
|
||||
mockInstanceSettingsService.getExperimental.mockResolvedValue({ enableNativeRunner: true });
|
||||
it.each(["create", "hire", "convert"])("saves an unpaired Dot configuration independently of the Runner rollout for %s while refusing task admission", async mode => {
|
||||
mockInstanceSettingsService.getExperimental.mockResolvedValue({ enableNativeRunner: false, enableOpenAiDot: true });
|
||||
const app = await createApp();
|
||||
const config = { provider: "openai_dot", lifecycleMode: "per_turn", allowUnmeteredProvider: true };
|
||||
const response = await requestApp(app, baseUrl => mode === "create"
|
||||
? request(baseUrl).post("/api/companies/company-1/agents").send({ name: "Dot", adapterType: "paperclip_runner", adapterConfig: config })
|
||||
: mode === "hire" ? request(baseUrl).post("/api/companies/company-1/agent-hires").send({ name: "Dot", role: "engineer", adapterType: "paperclip_runner", adapterConfig: config })
|
||||
: request(baseUrl).patch("/api/agents/11111111-1111-4111-8111-111111111111").send({ adapterType: "paperclip_runner", replaceAdapterConfig: true, adapterConfig: config }));
|
||||
expect(response.status, JSON.stringify(response.body)).toBe(mode === "create" ? 201 : 200);
|
||||
expect(response.body.adapterConfig).toMatchObject(config);
|
||||
expect(response.body.adapterConfig.model).toBeUndefined();
|
||||
expect(response.body.adapterConfig.codexPermissionMode).toBeUndefined();
|
||||
expect(response.status, JSON.stringify(response.body)).toBe(mode === "convert" ? 200 : 201);
|
||||
const saved = mode === "hire" ? response.body.agent : response.body;
|
||||
expect(saved.adapterConfig).toMatchObject(config);
|
||||
expect(saved.adapterConfig.model).toBeUndefined();
|
||||
expect(saved.adapterConfig.codexPermissionMode).toBeUndefined();
|
||||
const { resolvePaperclipRunnerProviderProfile } = await import("../services/native-runtime/provider-profile.js");
|
||||
expect(() => resolvePaperclipRunnerProviderProfile(response.body.adapterConfig)).toThrow(expect.objectContaining({ code: "paperclip_runner_dot_config_invalid" }));
|
||||
expect(() => resolvePaperclipRunnerProviderProfile(saved.adapterConfig)).toThrow(expect.objectContaining({ code: "paperclip_runner_dot_config_invalid" }));
|
||||
});
|
||||
|
||||
it.each(["create", "hire", "convert"])("rejects a new Dot selection when only the general Runner rollout is enabled for %s", async mode => {
|
||||
mockInstanceSettingsService.getExperimental.mockResolvedValue({ enableNativeRunner: true, enableOpenAiDot: false });
|
||||
const app = await createApp();
|
||||
const body = { name: "Dot", role: "engineer", adapterType: "paperclip_runner", adapterConfig: { provider: "openai_dot", allowUnmeteredProvider: true } };
|
||||
const response = await requestApp(app, baseUrl => mode === "convert"
|
||||
? request(baseUrl).patch("/api/agents/11111111-1111-4111-8111-111111111111").send(body)
|
||||
: request(baseUrl).post(`/api/companies/company-1/${mode === "hire" ? "agent-hires" : "agents"}`).send(body));
|
||||
expect(response.status, JSON.stringify(response.body)).toBe(422);
|
||||
expect(response.body.details).toMatchObject({ code: "paperclip_runner_dot_disabled" });
|
||||
expect(mockAgentService.create).not.toHaveBeenCalled();
|
||||
expect(mockAgentService.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("requires the general Runner rollout when changing an existing Dot to Codex", async () => {
|
||||
mockInstanceSettingsService.getExperimental.mockResolvedValue({ enableNativeRunner: false, enableOpenAiDot: true });
|
||||
mockAgentService.getById.mockResolvedValue({ ...(await mockAgentService.getById()), adapterType: "paperclip_runner", adapterConfig: { provider: "openai_dot", allowUnmeteredProvider: true } });
|
||||
const app = await createApp();
|
||||
const response = await requestApp(app, baseUrl => request(baseUrl).patch("/api/agents/11111111-1111-4111-8111-111111111111")
|
||||
.send({ adapterConfig: { provider: "codex" }, replaceAdapterConfig: true }));
|
||||
expect(response.status, JSON.stringify(response.body)).toBe(422);
|
||||
expect(response.body.details).toMatchObject({ code: "paperclip_runner_rollout_disabled" });
|
||||
expect(mockAgentService.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("normalizes legacy skills and permissions when switching to paperclip_runner", async () => {
|
||||
|
||||
@@ -2202,11 +2202,16 @@ export function agentRoutes(
|
||||
* (listEnabledServerAdapters documents the same rule: hidden from selection,
|
||||
* still functional for agents that already use them).
|
||||
*/
|
||||
async function assertSelectableAdapterType(type: string | null | undefined): Promise<string> {
|
||||
async function assertSelectableAdapterType(type: string | null | undefined, config?: unknown): Promise<string> {
|
||||
const adapterType = assertKnownAdapterType(type);
|
||||
if (adapterType === "paperclip_runner") {
|
||||
const experimental = await instanceSettings.getExperimental();
|
||||
if (experimental.enableNativeRunner !== true) {
|
||||
if (asRecord(config)?.provider === "openai_dot") {
|
||||
if (experimental.enableOpenAiDot !== true) throw unprocessable(
|
||||
"OpenAI Dot is experimental and disabled on this instance.",
|
||||
{ code: "paperclip_runner_dot_disabled" },
|
||||
);
|
||||
} else if (experimental.enableNativeRunner !== true) {
|
||||
throw unprocessable(
|
||||
"Paperclip Runner is experimental and disabled on this instance.",
|
||||
{ code: "paperclip_runner_rollout_disabled" },
|
||||
@@ -4515,10 +4520,13 @@ export function agentRoutes(
|
||||
? rollbackConfig.adapterType
|
||||
: null,
|
||||
);
|
||||
if (rollbackAdapterType !== existing.adapterType) {
|
||||
await assertSelectableAdapterType(rollbackAdapterType);
|
||||
}
|
||||
const rollbackAdapterConfig = asRecord(rollbackConfig.adapterConfig) ?? {};
|
||||
const existingRollbackProvider = asRecord(existing.adapterConfig)?.provider;
|
||||
if (rollbackAdapterType !== existing.adapterType || (rollbackAdapterType === "paperclip_runner"
|
||||
&& rollbackAdapterConfig.provider !== existingRollbackProvider
|
||||
&& (rollbackAdapterConfig.provider === "openai_dot" || existingRollbackProvider === "openai_dot"))) {
|
||||
await assertSelectableAdapterType(rollbackAdapterType, rollbackAdapterConfig);
|
||||
}
|
||||
assertNoAgentAdapterConfigMutation(req, rollbackAdapterConfig);
|
||||
assertNoAgentLocalAdapterHostCommandMutation(req, rollbackAdapterType, rollbackAdapterConfig);
|
||||
assertNoAgentProcessAdapterMutation(
|
||||
@@ -4675,7 +4683,7 @@ export function agentRoutes(
|
||||
hireInput.adapterConfig = inheritNativeRunnerAdapterConfig(caller.adapterConfig);
|
||||
hireInput.defaultEnvironmentId = caller.defaultEnvironmentId ?? null;
|
||||
}
|
||||
hireInput.adapterType = await assertSelectableAdapterType(hireInput.adapterType);
|
||||
hireInput.adapterType = await assertSelectableAdapterType(hireInput.adapterType, hireInput.adapterConfig);
|
||||
const rawHireAdapterConfig = (hireInput.adapterConfig ?? {}) as Record<string, unknown>;
|
||||
assertProviderTraceSettingTransition(req, hireInput.runtimeConfig);
|
||||
await assertFreshPaperclipRunnerProvider(
|
||||
@@ -4987,7 +4995,7 @@ export function agentRoutes(
|
||||
onboardingFirstAgent: createOnboardingFirstAgent,
|
||||
...createInput
|
||||
} = req.body;
|
||||
createInput.adapterType = await assertSelectableAdapterType(createInput.adapterType);
|
||||
createInput.adapterType = await assertSelectableAdapterType(createInput.adapterType, createInput.adapterConfig);
|
||||
const rawCreateAdapterConfig = (createInput.adapterConfig ?? {}) as Record<string, unknown>;
|
||||
assertProviderTraceSettingTransition(req, createInput.runtimeConfig);
|
||||
await assertFreshPaperclipRunnerProvider(
|
||||
@@ -5631,7 +5639,7 @@ export function agentRoutes(
|
||||
: existing.adapterType;
|
||||
const requestedAdapterType = nextAdapterType === existing.adapterType
|
||||
? nextAdapterType
|
||||
: await assertSelectableAdapterType(nextAdapterType);
|
||||
: await assertSelectableAdapterType(nextAdapterType, patchData.adapterConfig);
|
||||
let requestedRuntimeConfig: Record<string, unknown> | null = null;
|
||||
if (hasOwn(patchData, "runtimeConfig")) {
|
||||
const runtimeConfig = asRecord(patchData.runtimeConfig);
|
||||
@@ -5711,6 +5719,13 @@ export function agentRoutes(
|
||||
existing.adapterType === "paperclip_runner"
|
||||
? existingAdapterConfig.provider
|
||||
: undefined;
|
||||
// Moving between the standalone Dot choice and general Runner is a new
|
||||
// selection. Preserve historical edits within the general Runner rollout.
|
||||
if (!changingAdapterType && requestedAdapterType === "paperclip_runner"
|
||||
&& rawEffectiveAdapterConfig.provider !== existingRunnerProvider
|
||||
&& (rawEffectiveAdapterConfig.provider === "openai_dot" || existingRunnerProvider === "openai_dot")) {
|
||||
await assertSelectableAdapterType(requestedAdapterType, rawEffectiveAdapterConfig);
|
||||
}
|
||||
if (
|
||||
changingAdapterType ||
|
||||
(requestedAdapterType === "paperclip_runner" &&
|
||||
|
||||
@@ -38,20 +38,30 @@ const copy = {
|
||||
},
|
||||
} as const;
|
||||
|
||||
const prerequisiteCopy = {
|
||||
idle: { title: "Check prerequisites", description: "Check instance settings and public connection configuration.", action: "Check setup" },
|
||||
running: { title: "Checking prerequisites", description: "Checking instance settings and public connection configuration…", action: "Checking…" },
|
||||
pass: { title: "Setup checks passed", description: "Complete pairing and verify event delivery before assigning work.", action: "Check again" },
|
||||
warn: { title: "Pairing required", description: "Create this agent, then pair it and verify event delivery.", action: "Check again" },
|
||||
fail: { title: "Prerequisite check failed", description: "Review the setup details and retry.", action: "Retry check" },
|
||||
} as const;
|
||||
|
||||
export function RuntimeTestCard({
|
||||
state,
|
||||
result,
|
||||
error,
|
||||
onTest,
|
||||
disabled = false,
|
||||
variant = "connection",
|
||||
}: {
|
||||
state: TestState | "warn";
|
||||
result: AdapterEnvironmentTestResult | null;
|
||||
error?: string | null;
|
||||
onTest: () => void;
|
||||
disabled?: boolean;
|
||||
variant?: "connection" | "prerequisites";
|
||||
}) {
|
||||
const content = copy[state];
|
||||
const content = (variant === "prerequisites" ? prerequisiteCopy : copy)[state];
|
||||
const Icon =
|
||||
state === "running"
|
||||
? Loader2
|
||||
|
||||
@@ -1161,6 +1161,7 @@ function Setup({
|
||||
)}
|
||||
</fieldset>
|
||||
<RuntimeTestCard
|
||||
variant={isDot ? "prerequisites" : "connection"}
|
||||
state={testState}
|
||||
result={result}
|
||||
error={error}
|
||||
|
||||
@@ -206,6 +206,8 @@ describe("New agent setup", () => {
|
||||
expect(container.textContent).toContain("OpenAI Dot");
|
||||
expect(container.querySelector('[aria-label="Model"]')).toBeNull();
|
||||
expect(container.textContent).not.toContain("Connect a model");
|
||||
expect(container.textContent).toContain("Check prerequisites");
|
||||
expect(container.textContent).not.toContain("runtime and model can respond");
|
||||
await click("Finish setup");
|
||||
expect(api.hire).not.toHaveBeenCalled();
|
||||
expect(container.textContent).toContain("Acknowledge external provider billing");
|
||||
|
||||
Reference in new issue
Block a user