Files
PaperClipAI/packages/adapters/cursor-cloud/src/server/execute.test.ts
T
DottaandPaperclip ebaeba40ee feat: simplify agent onboarding and configuration (#13011)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work.
> - Operators create agents and configure their runtimes in the board
UI.
> - The old creation flow presents several choices and a large form
before an agent can start.
> - The existing onboarding controls already provide clear provider
connection steps.
> - This pull request uses those controls in a new-agent wizard and
organizes the full configuration pages.
> - Operators can connect, test, save, and assign a first task while
keeping the existing configuration tools.

## Linked Issues or Issue Description

Related: #10974. That earlier open PR also reorganizes agent
configuration. This PR follows the reviewed Storybook designs for agent
creation and the current configuration tabs.

**What existing behavior does this improve?**

Agent creation, provider connection, runtime tests, and full agent
configuration.

**Current behavior**

The creation dialog leads to a large manual configuration form. Provider
login controls differ from onboarding. Environment variables and secret
access appear in separate places.

**Proposed behavior**

Choose a name and adapter. Connect Claude or Codex through the existing
onboarding controls. Configure and test the runtime, save the agent, and
open a task dialog with that agent assigned. Use the same design on the
existing configuration tabs.

**Reason and benefit**

The first setup asks for fewer decisions. The full editor keeps
instructions, skills, runtime controls, secret access, permissions,
keys, and revisions available in clear sections.

**Breaking changes**

The board creation and configuration layouts change. The
test-environment API adds an optional, allowlisted `testCredentials`
field for one-shot probes. Database contracts stay the same. Native ACPX
tests now reject unsupported local platforms before a CLI login can mask
the runtime restriction.

## What Changed

- Added a new-agent wizard with numbered steps, adapter branding,
provider connections, editable model choices, runtime tests, and
confirmation.
- Added Codex app-server, Claude ACPX, and OpenCode runner choices.
- Stored API credentials through existing secret APIs and persisted
references in agent configuration. New setup keys are isolated from
credentials used by existing agents.
- Preserved external-agent invitations beside the wizard, including
optional messages, one-time prompts, and clipboard fallback.
- Added OpenRouter provider and secret bindings for Pi and OpenCode.
- Added adapter-specific prerequisite fields for Cursor, Gemini, Kimi,
and Hermes. Cursor Cloud keys are saved as new organization secrets.
- Fixed Cursor Cloud repository field mapping, omitted empty remote
environment values, and added useful model and repository error
messages.
- Preserved complete MCP assignments when multiple valid profiles
contain more than 250 tools in total. Generated profiles retain exact
tool selectors.
- Added service branding and deployment-aware adapter choices. Cloud
setup offers Claude, Codex, and OpenCode; local native runners require
the experimental setting.
- Made the agent list responsive at intermediate widths.
- Applied the reviewed design to the real agent configuration pages.
Kept the instruction editor, skills, and existing mutations.
- Combined secret access and environment variables under one Save and
Discard action.
- Added interactive Storybook screens for setup, configuration,
confirmation, authentication, and test results.
- Fixed Pi provider-error parsing and thinking-effort persistence.
Native ACPX validates Linux x64 on the actual local, SSH, or sandbox
target.
- Redacted the complete transient probe-credential field from HTTP error
logs, including rejected provider names.

## Verification

- Current head `df0292fe6` has a fresh Greptile 5/5 review with no
unresolved findings. All 31 executed CI checks passed, including the
aggregate verification gate and all browser E2E shards. Storybook visual
regression is skipped by its workflow; the local Storybook build passed.

- Browser tests completed real assigned tasks with direct Codex, Claude,
OpenCode, Pi, and native Codex.
- Verified external-agent invitation generation and automatic prompt
copying in the live browser.
- Pi and OpenCode used an existing OpenRouter secret. Browser checks
covered save and reload, instruction edits, skill selection,
environment-variable Save and Discard, and assigned task creation.
- Invalid Claude API credentials remained on the connection step with an
error. A live Pi/OpenRouter invalid-key probe returned a provider
failure and left the user-secret inventory unchanged (zero entries
before and after).
- Full workspace typecheck and build passed after rebasing onto current
master. After review fixes, server and UI typechecks, token gates, and
the full build passed again. Storybook built successfully.
- All 5,542 local UI tests passed. The Cursor Cloud and Pi adapter
regressions passed all 24 tests. Review regressions passed 69 server
tests and all 18 agent-list tests.
- The local full test command ran 6,971 general server tests
successfully. Editing review fixes during that long run caused nine
tests to use stale modules; fresh isolated runs passed. An unrelated
embedded-Postgres fixture hit the host shared-memory limit; its 15
affected tests passed when the fixture groups ran separately.
- Local workspace groups passed after rerunning 18 CLI tests
sequentially to avoid host database limits and parallel-load timeouts.
The local full command stopped at the general server phase, so
serialized server verification comes from the five passing CI shards.
- Browser testing at 390px confirmed that the agent action menu opens
and the page has no horizontal overflow. CI browser E2E shards passed.
- Review the `Onboarding / New agent` and `Agents / Configuration
refresh` Storybook groups. In the real app, create an agent, run its
connection test, save it, assign a task, and reload its configuration.

## Risks

- This changes the main agent setup and configuration UI. Regression
tests cover routing, persistence, secret bindings, and form actions.
- Native Claude ACPX requires Linux x64. Direct Claude works on macOS.
Remote checks execute a bounded platform probe and reject unsupported or
unverified targets.
- A native OpenCode task reached the provider context limit because of
its tool payload. Its provider connection test passed. Direct OpenCode
completed a task. This existing native execution limit is not fixed
here.
- Claude and Codex connection keys use the existing user-secret store.
Other runtime setup keys use distinct organization secrets. Existing
credentials are never rotated. Probes do not store entered keys. Failed
agent creation removes newly staged credentials.
- Cursor Cloud has not completed a live task. Its authenticated account
still needs GitHub repository access. The live run passed MCP
provisioning, remote environment validation, and explicit Auto model
selection before the repository prerequisite blocked execution.
- Generated runtime MCP profiles can exceed the public profile-edit
request limit. They still contain exact catalog selectors and preserve
permission boundaries.
- No database migration, dependency, lockfile, or workflow changes are
included.

## Model Used

OpenAI Codex, based on GPT-6, with reasoning, repository tools, shell
execution, and browser automation. The runtime did not expose the exact
model ID or context window size.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] All Paperclip CI gates are green
- [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge

---------

Co-authored-by: Paperclip <noreply@paperclip.ing>
2026-09-08 14:22:32 -05:00

444 lines
14 KiB
TypeScript

import { beforeEach, describe, expect, it, vi } from "vitest";
import type { AdapterExecutionContext } from "@paperclipai/adapter-utils";
import { execute } from "./execute.js";
type MockRunOptions = {
id?: string;
agentId?: string;
status?: string;
waitResult?: Record<string, unknown>;
streamMessages?: unknown[];
streamError?: Error | null;
};
type MockAgentOptions = {
agentId?: string;
sendRun?: ReturnType<typeof createMockRun>;
};
const { createMock, resumeMock, getRunMock } = vi.hoisted(() => ({
createMock: vi.fn(),
resumeMock: vi.fn(),
getRunMock: vi.fn(),
}));
vi.mock("@cursor/sdk", () => ({
Agent: {
create: createMock,
resume: resumeMock,
getRun: getRunMock,
},
}));
function createMockRun(options: MockRunOptions = {}) {
const runId = options.id ?? "run-123";
const agentId = options.agentId ?? "agent-123";
const status = options.status ?? "finished";
const waitResult = options.waitResult ?? {
id: runId,
status,
result: "Done\nWith detail",
model: { id: "gpt-5.4" },
durationMs: 1234,
};
const streamMessages = options.streamMessages ?? [];
const streamError = options.streamError ?? null;
return {
id: runId,
agentId,
status,
result: typeof waitResult.result === "string" ? waitResult.result : null,
model: waitResult.model ?? null,
durationMs: waitResult.durationMs ?? null,
git: waitResult.git ?? null,
supports(capability: string) {
return capability === "stream" || capability === "wait";
},
async *stream() {
for (const message of streamMessages) yield message;
if (streamError) throw streamError;
},
async wait() {
return waitResult;
},
};
}
function createMockSdkAgent(options: MockAgentOptions = {}) {
const sendRun = options.sendRun ?? createMockRun();
return {
agentId: options.agentId ?? sendRun.agentId,
send: vi.fn(async () => sendRun),
[Symbol.asyncDispose]: vi.fn(async () => {}),
};
}
function createContext(
overrides: Partial<AdapterExecutionContext> = {},
): AdapterExecutionContext & {
logs: Array<{ stream: "stdout" | "stderr"; chunk: string }>;
meta: Record<string, unknown>[];
} {
const logs: Array<{ stream: "stdout" | "stderr"; chunk: string }> = [];
const meta: Record<string, unknown>[] = [];
const agent = overrides.agent ?? {
id: "agent-1",
companyId: "company-1",
name: "Cursor Cloud Agent",
adapterType: "cursor_cloud",
adapterConfig: {},
};
const runtime = overrides.runtime ?? {
sessionId: null,
sessionParams: null,
sessionDisplayId: null,
taskKey: null,
};
const config = overrides.config ?? {
env: {
CURSOR_API_KEY: "cursor-secret",
EXTRA_FLAG: "1",
},
repoUrl: "https://github.com/paperclipai/paperclip.git",
repoStartingRef: "main",
runtimeEnvType: "cloud",
promptTemplate: "Do the work for {{agent.name}}",
model: "gpt-5.4",
};
const context = overrides.context ?? {
taskId: "issue-1",
issueId: "issue-1",
wakeReason: "issue_commented",
};
const base: AdapterExecutionContext = {
runId: "run-heartbeat-1",
agent,
runtime,
config,
context,
authToken: "paperclip-run-jwt",
onLog: async (stream, chunk) => {
logs.push({ stream, chunk });
},
onMeta: async (entry) => {
meta.push(entry as unknown as Record<string, unknown>);
},
};
return {
...base,
...overrides,
logs,
meta,
};
}
describe("cursor_cloud execute", () => {
beforeEach(() => {
createMock.mockReset();
resumeMock.mockReset();
getRunMock.mockReset();
});
it("creates a fresh Cursor agent and injects Paperclip env without CURSOR_API_KEY", async () => {
const run = createMockRun({
agentId: "agent-fresh",
streamMessages: [
{
type: "assistant",
message: {
content: [{ type: "text", text: "Working" }],
},
},
],
});
const sdkAgent = createMockSdkAgent({ agentId: "agent-fresh", sendRun: run });
createMock.mockResolvedValue(sdkAgent);
const ctx = createContext();
const result = await execute(ctx);
expect(createMock).toHaveBeenCalledTimes(1);
expect(resumeMock).not.toHaveBeenCalled();
expect(getRunMock).not.toHaveBeenCalled();
expect(createMock.mock.calls[0]?.[0]).toMatchObject({
apiKey: "cursor-secret",
name: "Paperclip Cursor Cloud Agent",
model: { id: "gpt-5.4" },
cloud: {
env: { type: "cloud" },
repos: [{ url: "https://github.com/paperclipai/paperclip.git", startingRef: "main" }],
},
});
expect(createMock.mock.calls[0]?.[0]?.cloud?.envVars).toMatchObject({
EXTRA_FLAG: "1",
PAPERCLIP_RUN_ID: "run-heartbeat-1",
PAPERCLIP_TASK_ID: "issue-1",
PAPERCLIP_WAKE_REASON: "issue_commented",
PAPERCLIP_API_KEY: "paperclip-run-jwt",
});
// When a run JWT is present the callback URL is retained so the worker can
// authenticate its Paperclip API calls.
expect(createMock.mock.calls[0]?.[0]?.cloud?.envVars).toHaveProperty("PAPERCLIP_API_URL");
expect(createMock.mock.calls[0]?.[0]?.cloud?.envVars).not.toHaveProperty("CURSOR_API_KEY");
expect(result).toMatchObject({
exitCode: 0,
errorMessage: null,
sessionId: "agent-fresh",
model: "gpt-5.4",
summary: "Done",
sessionParams: {
cursorAgentId: "agent-fresh",
latestRunId: "run-123",
runtime: "cloud",
envType: "cloud",
repos: [{ url: "https://github.com/paperclipai/paperclip.git", startingRef: "main" }],
},
});
expect(ctx.logs.map((entry) => entry.chunk)).toEqual(
expect.arrayContaining([
expect.stringContaining('"type":"cursor_cloud.init"'),
expect.stringContaining('"type":"cursor_cloud.message"'),
expect.stringContaining('"type":"cursor_cloud.result"'),
]),
);
});
it("omits empty environment values while preserving nonempty values exactly", async () => {
createMock.mockResolvedValue(createMockSdkAgent());
const ctx = createContext();
ctx.config.env = {
CURSOR_API_KEY: "cursor-secret",
GH_TOKEN: "",
GITHUB_TOKEN: "",
GIT_AUTHOR_NAME: "",
SSH_AUTH_SOCK: "",
EMPTY_PLAIN: { type: "plain", value: "" },
EXTRA_FLAG: "0",
PADDED_VALUE: " retain whitespace ",
};
await execute(ctx);
const env = createMock.mock.calls[0]?.[0]?.cloud?.envVars;
expect(env).not.toHaveProperty("GH_TOKEN");
expect(env).not.toHaveProperty("GITHUB_TOKEN");
expect(env).not.toHaveProperty("GIT_AUTHOR_NAME");
expect(env).not.toHaveProperty("SSH_AUTH_SOCK");
expect(env).not.toHaveProperty("EMPTY_PLAIN");
expect(env).not.toHaveProperty("CURSOR_API_KEY");
expect(env).toMatchObject({ EXTRA_FLAG: "0", PADDED_VALUE: " retain whitespace " });
expect(Object.values(env).every((value) => value !== "")).toBe(true);
});
it("reports dispatch before starting the first remote SDK operation", async () => {
const run = createMockRun({ agentId: "agent-dispatch" });
const sdkAgent = createMockSdkAgent({ agentId: "agent-dispatch", sendRun: run });
createMock.mockResolvedValue(sdkAgent);
const onDispatch = vi.fn();
await execute(createContext({ onDispatch }));
expect(onDispatch).toHaveBeenCalledTimes(1);
expect(onDispatch.mock.invocationCallOrder[0]).toBeLessThan(createMock.mock.invocationCallOrder[0]!);
});
it("omits the Paperclip API callback when no run JWT is issued (remote worker cannot call home)", async () => {
const run = createMockRun({ agentId: "agent-no-jwt" });
const sdkAgent = createMockSdkAgent({ agentId: "agent-no-jwt", sendRun: run });
createMock.mockResolvedValue(sdkAgent);
// cursor_cloud is registered with supportsLocalAgentJwt=false, so heartbeat
// passes no authToken. A remote cloud worker must not receive a callback URL
// it can neither reach nor authenticate against (the source of 401 noise).
const ctx = createContext({ authToken: undefined });
await execute(ctx);
const envVars = (createMock.mock.calls[0]?.[0]?.cloud?.envVars ?? {}) as Record<string, string>;
expect(envVars).not.toHaveProperty("PAPERCLIP_API_KEY");
expect(envVars).not.toHaveProperty("PAPERCLIP_API_URL");
expect(envVars).not.toHaveProperty("PAPERCLIP_API_BRIDGE_MODE");
// Informational Paperclip env (non-credential) still flows through.
expect(envVars).toMatchObject({
PAPERCLIP_RUN_ID: "run-heartbeat-1",
PAPERCLIP_AGENT_ID: "agent-1",
PAPERCLIP_COMPANY_ID: "company-1",
});
});
it("resumes a matching saved session when no active run can be reattached", async () => {
getRunMock.mockResolvedValue(createMockRun({ status: "finished" }));
const resumedRun = createMockRun({ id: "run-resumed", agentId: "agent-resumed" });
const sdkAgent = createMockSdkAgent({ agentId: "agent-resumed", sendRun: resumedRun });
resumeMock.mockResolvedValue(sdkAgent);
const ctx = createContext({
runtime: {
sessionId: null,
sessionDisplayId: "agent-previous",
taskKey: null,
sessionParams: {
cursorAgentId: "agent-previous",
latestRunId: "run-previous",
runtime: "cloud",
envType: "cloud",
repos: [{ url: "https://github.com/paperclipai/paperclip.git", startingRef: "main" }],
},
},
});
const result = await execute(ctx);
expect(getRunMock).toHaveBeenCalledWith("run-previous", {
runtime: "cloud",
agentId: "agent-previous",
apiKey: "cursor-secret",
});
expect(resumeMock).toHaveBeenCalledTimes(1);
expect(createMock).not.toHaveBeenCalled();
expect(sdkAgent.send).toHaveBeenCalledTimes(1);
expect(result.sessionId).toBe("agent-resumed");
});
it("reattaches to an active run, drains it, then sends the heartbeat as a follow-up", async () => {
const attachedRun = createMockRun({
id: "run-attached",
agentId: "agent-attached",
status: "running",
waitResult: {
id: "run-attached",
status: "finished",
result: "Prior result",
model: { id: "gpt-5.4" },
},
streamMessages: [
{
type: "status",
status: "running",
message: "Still working",
},
],
});
getRunMock.mockResolvedValue(attachedRun);
const followUpRun = createMockRun({
id: "run-followup",
agentId: "agent-attached",
waitResult: {
id: "run-followup",
status: "finished",
result: "Follow-up result",
model: { id: "gpt-5.4" },
},
});
const sdkAgent = createMockSdkAgent({ agentId: "agent-attached", sendRun: followUpRun });
resumeMock.mockResolvedValue(sdkAgent);
const ctx = createContext({
runtime: {
sessionId: null,
sessionDisplayId: "agent-attached",
taskKey: null,
sessionParams: {
cursorAgentId: "agent-attached",
latestRunId: "run-attached",
runtime: "cloud",
envType: "cloud",
repos: [{ url: "https://github.com/paperclipai/paperclip.git", startingRef: "main" }],
},
},
});
const result = await execute(ctx);
expect(getRunMock).toHaveBeenCalledTimes(1);
expect(createMock).not.toHaveBeenCalled();
expect(resumeMock).toHaveBeenCalledTimes(1);
expect(sdkAgent.send).toHaveBeenCalledTimes(1);
expect(result).toMatchObject({
exitCode: 0,
sessionId: "agent-attached",
summary: "Follow-up result",
resultJson: {
cursorRunId: "run-followup",
},
});
const logChunks = ctx.logs.map((entry) => entry.chunk);
expect(logChunks).toEqual(
expect.arrayContaining([
expect.stringContaining("Reattached to existing Cursor run run-attached."),
expect.stringContaining("Prior Cursor run run-attached finished"),
expect.stringContaining("Started Cursor run run-followup."),
expect.stringContaining('"runId":"run-attached"'),
expect.stringContaining('"runId":"run-followup"'),
]),
);
expect(ctx.meta[0]?.context).toMatchObject({
cursorCloud: {
canReuseSession: true,
repoUrl: "https://github.com/paperclipai/paperclip.git",
},
});
});
it("explains a rejected Cursor default without silently choosing another model", async () => {
const sdkAgent = createMockSdkAgent();
sdkAgent.send.mockRejectedValue(new Error("[invalid_model] Model 'gpt-5' is not available or invalid."));
createMock.mockResolvedValue(sdkAgent);
const ctx = createContext();
delete ctx.config.model;
const result = await execute(ctx);
expect(createMock.mock.calls[0]?.[0]).not.toHaveProperty("model");
expect(sdkAgent.send).toHaveBeenCalledWith(expect.any(String), {});
expect(result.exitCode).toBe(1);
expect(result.errorMessage).toContain("Cursor rejected its configured default model");
expect(result.errorMessage).toContain("https://cursor.com/dashboard/cloud-agents");
expect(sdkAgent.send).toHaveBeenCalledTimes(1);
});
it("makes Cursor repository setup failures actionable without launching another run", async () => {
const sdkAgent = createMockSdkAgent();
sdkAgent.send.mockRejectedValue(new Error("[validation_error] Failed to determine repository default branch"));
createMock.mockResolvedValue(sdkAgent);
const result = await execute(createContext());
expect(result.exitCode).toBe(1);
expect(result.errorMessage).toContain("Cursor's GitHub integration can access https://github.com/paperclipai/paperclip.git");
expect(result.errorMessage).toContain("https://cursor.com/dashboard/cloud-agents");
expect(sdkAgent.send).toHaveBeenCalledTimes(1);
});
it("maps non-finished Cursor results to failing Paperclip runs", async () => {
const cancelledRun = createMockRun({
id: "run-cancelled",
agentId: "agent-cancelled",
status: "cancelled",
waitResult: {
id: "run-cancelled",
status: "cancelled",
result: "",
model: { id: "gpt-5.4" },
},
});
const sdkAgent = createMockSdkAgent({ agentId: "agent-cancelled", sendRun: cancelledRun });
createMock.mockResolvedValue(sdkAgent);
const ctx = createContext();
const result = await execute(ctx);
expect(result).toMatchObject({
exitCode: 1,
errorMessage: "Cursor run cancelled",
sessionId: "agent-cancelled",
resultJson: {
status: "cancelled",
cursorAgentId: "agent-cancelled",
cursorRunId: "run-cancelled",
},
});
});
});