feat(cloud-agent): Cursor Cloud Agent via official REST API key (#4227) (#4250)

Integrated into release/v3.8.29 — Cursor Cloud Agent (cursor-cloud) via official api.cursor.com REST API key, 4th cloud agent mirroring Devin/Jules.

Pre-merge work done on the PR branch (fix-in-place):
- Wired cursor-cloud into the dashboard UI (CLOUD_AGENTS dropdown), health PROVIDER_NAMES, and lobeProviderIcons — it was API-usable but not selectable in the UI and rendered with the Jules fallback.
- Fixed cloud-agent-health-api.test.ts which asserted exactly 3 agents (the PR added a 4th) -> updated count/order + added cursor-cloud coverage.
- file-size baseline: page.tsx 913->922.

FOLLOW-UP (Rule #18): the REST endpoint paths/field names follow Cursor's documented Cloud Agents v0 API but still need a live validation run against a real Cursor API key. The integration is additive/opt-in and baseUrl is overridable per-credential, so any path/version correction needs no code change. Thanks @MRDGH2821 for the issue (#4227).
This commit is contained in:
Diego Rodrigues de Sa e Souza
2026-06-19 02:21:59 -03:00
committed by GitHub
parent 3bc1a0f6de
commit 1079fc36f0
13 changed files with 403 additions and 11 deletions

View File

@@ -10,6 +10,8 @@ _In development — bullets added per PR; finalized at release._
### ✨ New Features
- **feat(cloud-agent): Cursor Cloud Agent via the official API-key REST API (no IDE-OAuth ban risk)** — adds a `cursor-cloud` cloud agent that drives Cursor's Background / Cloud Agents through the official REST API (`api.cursor.com`) authenticated with a user or service-account API key — the safer, first-party alternative to re-using the Cursor IDE's OAuth session (the existing `cursor` provider, which carries a ban-risk warning). Implemented as a plain REST adapter mirroring the Devin/Jules agents (`createTask`/`getStatus`/`sendMessage`/`listSources`), so it does **not** pull in the `@cursor/sdk` package and its per-platform native binaries (Cursor's SDK is itself a thin wrapper over this REST API). Cursor's UPPERCASE status enums (`CREATING`/`RUNNING`/`FINISHED`/`ERROR`) are mapped explicitly to the shared `CloudAgentStatus`, and `baseUrl` is overridable per-credential. Credentials are stored encrypted via the existing `cloud_agent_credentials` table; no schema change. ([#4227](https://github.com/diegosouzapw/OmniRoute/issues/4227) — thanks @MRDGH2821)
- **feat(routing): OpenRouter-style `auto/<category>:<tier>` combos** — auto-routing now understands suffixed combos that separate the *category* (what kind of route) from the *tier* (how to optimize): `auto/coding:fast`, `auto/coding:cheap` (alias `:floor`), `auto/coding:free`, `auto/coding:pro`, `auto/coding:reliable`, plus the new category roots `auto/reasoning`, `auto/vision`, `auto/multimodal`. The **tier** picks the scoring weights — `:fast` → ship-fast, `:cheap`/`:floor` → cost-saver, `:reliable` → a new reliability-first pack (circuit-breaker health + latency stability) — while `:free`/`:pro` filter the candidate pool by model tier (`classifyTier`: free-tier vs. premium models). The **category** filters the pool by capability (`vision`/`multimodal` → vision-capable models, `reasoning` → reasoning/thinking models). Any valid `auto/<category>:<tier>` resolves on demand; a curated set is advertised in `/v1/models` and the dashboard. Filtering is fail-open — if a constraint matches no connected models the full pool is used so routing never breaks. All composition lives in the new `open-sse/services/autoCombo/suffixComposition.ts`; the core combo scorer (`combo.ts`) is untouched. Second slice of #4235 (premium account-tier weighting is a later follow-up). ([#4235](https://github.com/diegosouzapw/OmniRoute/issues/4235) — thanks @MRDGH2821)
- **feat(routing): OpenRouter-style `auto/<category>:<tier>` combos** — auto-routing now understands suffixed combos that separate the _category_ (what kind of route) from the _tier_ (how to optimize): `auto/coding:fast`, `auto/coding:cheap` (alias `:floor`), `auto/coding:free`, `auto/coding:pro`, `auto/coding:reliable`, plus the new category roots `auto/reasoning`, `auto/vision`, `auto/multimodal`. The **tier** picks the scoring weights — `:fast` → ship-fast, `:cheap`/`:floor` → cost-saver, `:reliable` → a new reliability-first pack (circuit-breaker health + latency stability) — while `:free`/`:pro` filter the candidate pool by model tier (`classifyTier`: free-tier vs. premium models). The **category** filters the pool by capability (`vision`/`multimodal` → vision-capable models, `reasoning` → reasoning/thinking models). Any valid `auto/<category>:<tier>` resolves on demand; a curated set is advertised in `/v1/models` and the dashboard. Filtering is fail-open — if a constraint matches no connected models the full pool is used so routing never breaks. All composition lives in the new `open-sse/services/autoCombo/suffixComposition.ts`; the core combo scorer (`combo.ts`) is untouched. Second slice of #4235 (premium account-tier weighting is a later follow-up). ([#4235](https://github.com/diegosouzapw/OmniRoute/issues/4235) — thanks @MRDGH2821)
- **feat(routing): advertise the `auto/cheap`, `auto/offline`, `auto/smart` combos (catalog ↔ README sync)** — the README lists `auto/cheap` (cheapest-per-token first), `auto/offline` (most quota/rate-limit headroom first) and `auto/smart` (quality-first + 10% exploration), and they already resolved at request time via `parseAutoPrefix``createVirtualAutoCombo`. But they were missing from `AUTO_TEMPLATE_VARIANTS`, so `/v1/models` and the dashboard combos list (which iterate that catalog) never showed them — the catalog drifted from the docs (visible in the issue's screenshots). Added the three entries so they're advertised everywhere alongside the other built-in `auto/*` combos. First slice of #4235 (OpenRouter-style `auto/<category>:<tier>` suffixes + new categories follow). ([#4235](https://github.com/diegosouzapw/OmniRoute/issues/4235) — thanks @MRDGH2821)

View File

@@ -1,5 +1,6 @@
{
"_comment": "Catraca de tamanho (check-file-size.mjs). frozen so pode encolher; arquivos novos <= cap. --update ratcheta.",
"_rebaseline_2026_06_19_4227_cursor_cloud_ui": "PR #4250 (#4227 Cursor Cloud Agent) cross-layer UI wiring own growth: cloud-agents/page.tsx 913->922 (+9 = one CLOUD_AGENTS dropdown entry for the new `cursor-cloud` provider, mirroring the existing jules/devin/codex-cloud entries). The backend agent (cursor.ts) + registry/types/credentials-route landed in the PR, but the dashboard CLOUD_AGENTS list, health PROVIDER_NAMES, and lobeProviderIcons maps are hardcoded (client/server boundary — the registry can't be imported into the client page), so the agent was API-usable but not selectable in the UI and rendered with the Jules fallback name/icon. The +9 is the single presentational data entry at the existing hardcoded list; not extractable (it IS the list). The health-name + icon adds land in non-frozen files.",
"_rebaseline_2026_06_19_cost_telemetry_combo_prettier": "NOT this PR's logic change: combo.ts 2601->2605 (+4) is a pure Prettier reflow applied by the cost-telemetry-parity merge commit's lint-staged hook. The DEFAULT_WEIGHTS/ProviderCandidate/ScoringWeights import from ./autoCombo/scoring.ts is 102 chars (> printWidth 100) on release (it landed via a GitHub merge that never ran the local Prettier hook), so the hook wrapped it to the canonical 4-line form. Zero logic change (git diff -w empty); cost-telemetry-parity never edits combo.ts. Reverting is futile — Prettier re-wraps the 102-char import on any later commit. Bumped here so the merge surfacing the reflow carries its own baseline.",
"_rebaseline_2026_06_18_cost_telemetry_parity": "Cost Telemetry Parity own growth: chatCore.ts 5095->5097 (+2 net at the existing non-streaming success return chokepoint, ~line 4629). The inline `headers: { ...buildOmniRouteResponseMetaHeaders({…}) }` spread was converted into an explicit `responseHeaders` Record + a single attachOmniRouteMetaHeaders(responseHeaders, {…, requestId: skillRequestId}) call, routing this return through the new choke-point helper and threading the new identity header X-OmniRoute-Request-Id (skillRequestId). The now-orphaned buildOmniRouteResponseMetaHeaders import was dropped (only attachOmniRouteMetaHeaders is used here), netting the construction to +2. The reusable helper attachOmniRouteMetaHeaders + the always-on X-OmniRoute-Version header live in the (non-frozen) src/domain/omnirouteResponseMeta.ts. Cohesive telemetry growth at the response chokepoint; the +2 is the headers-bag construction needed to mutate-in-place rather than spread, not extractable without hiding the return boundary. Structural shrink of chatCore.ts tracked in #3501.",
"_rebaseline_2026_06_19_4235_auto_suffix_combos": "#4235 Phase B own growth: chat.ts 1471->1486 (+15 = auto/<category>:<tier> recognition at the existing zero-config auto-routing chokepoint — parseAutoSuffix wiring + autoSpec thread-through to createVirtualAutoCombo) and catalog.ts 1463->1465 (+2 = AUTO_SUFFIX_VARIANTS import + advertise the curated suffix combos in the existing /v1/models auto loop). All real logic (the parser, tier->weights, candidate filter) lives in the new open-sse/services/autoCombo/suffixComposition.ts (<cap); both edits are thin cohesive wiring at the single correct integration point, not extractable.",
@@ -103,7 +104,7 @@
"src/app/(dashboard)/dashboard/cache/media/MediaPageClient.tsx": 1105,
"src/app/(dashboard)/dashboard/cache/page.tsx": 841,
"src/app/(dashboard)/dashboard/cli-code/components/CodexToolCard.tsx": 894,
"src/app/(dashboard)/dashboard/cloud-agents/page.tsx": 913,
"src/app/(dashboard)/dashboard/cloud-agents/page.tsx": 922,
"src/app/(dashboard)/dashboard/combos/page.tsx": 4350,
"src/app/(dashboard)/dashboard/costs/CostOverviewTab.tsx": 1495,
"src/app/(dashboard)/dashboard/costs/quota-share/components/PoolWizard.tsx": 1007,

View File

@@ -9,8 +9,8 @@ lastUpdated: 2026-05-13
> **Source of truth:** `src/lib/cloudAgent/` and `src/app/api/v1/agents/tasks/`
> **Last updated:** 2026-05-13 — v3.8.0
OmniRoute orchestrates third-party cloud-hosted coding agents (Codex Cloud, Devin,
Jules) as long-running tasks. Each agent is wrapped behind a uniform interface so
OmniRoute orchestrates third-party cloud-hosted coding agents (Codex Cloud, Cursor,
Devin, Jules) as long-running tasks. Each agent is wrapped behind a uniform interface so
clients can submit a prompt + repo URL and receive results without dealing with
provider-specific APIs.
@@ -29,6 +29,7 @@ artifact, and supports follow-up messages and (in some providers) plan approval
| `jules` | `JulesAgent` | `src/lib/cloudAgent/agents/jules.ts` | `https://jules.googleapis.com/v1alpha` | Yes |
| `devin` | `DevinAgent` | `src/lib/cloudAgent/agents/devin.ts` | `https://api.devin.ai/v1` | Yes |
| `codex-cloud` | `CodexCloudAgent` | `src/lib/cloudAgent/agents/codex.ts` | `https://api.openai.com/v1/codex/cloud` | No (auto) |
| `cursor-cloud` | `CursorCloudAgent` | `src/lib/cloudAgent/agents/cursor.ts` | `https://api.cursor.com/v0` | No (auto) |
Registry: `src/lib/cloudAgent/registry.ts` — exports `getAgent(providerId)`,
`getAvailableAgents()`, and `isCloudAgentProvider(providerId)`. The registry is a
@@ -120,6 +121,16 @@ export abstract class CloudAgentBase {
`CodexCloudAgent.approvePlan` intentionally throws — Codex Cloud auto-plans and has
no approval gate. `CodexCloudAgent.listSources` returns `[]`.
`CursorCloudAgent` drives Cursor's Background / Cloud Agents through its official REST
API (`api.cursor.com/v0`) with a **user or service-account API key** — the safer,
first-party alternative to re-using the Cursor IDE's OAuth session (provider `cursor`,
which carries a ban-risk warning). It is a plain REST adapter (no `@cursor/sdk` native
dependency). `approvePlan` throws (Cursor agents run autonomously); `listSources` lists
the repositories reachable by the key. Cursor returns UPPERCASE status enums
(`CREATING`/`RUNNING`/`FINISHED`/`ERROR`), mapped explicitly to the shared
`CloudAgentStatus`. `baseUrl` is overridable per-credential so the API version/path can
be corrected without a code change.
## Domain Types
Source: `src/lib/cloudAgent/types.ts`
@@ -160,7 +171,7 @@ export interface CloudAgentActivity {
export interface CloudAgentTask {
id: string; // internal `task_...` id
providerId: "jules" | "devin" | "codex-cloud";
providerId: "jules" | "devin" | "codex-cloud" | "cursor-cloud";
externalId?: string; // upstream provider's id
status: CloudAgentStatus;
prompt: string; // 1..10000 chars

View File

@@ -440,6 +440,9 @@ export function extractCloudAgentRegistryKeys(registrySource: string): Set<strin
*/
export const AGENT_FILE_TO_REGISTRY_KEY: Record<string, string> = {
codex: "codex-cloud",
// #4227: file agents/cursor.ts ↔ registry key "cursor-cloud" (distinct from the
// OAuth chat provider `cursor`).
cursor: "cursor-cloud",
};
// ───────────────────────────────────────────────────────────────────────────

View File

@@ -61,6 +61,15 @@ const CLOUD_AGENTS = [
iconBg: "bg-emerald-500/10",
iconColor: "text-emerald-600",
},
{
id: "cursor-cloud",
name: "Cursor Cloud",
provider: "Cursor",
description: "Cursor's Background / Cloud Agents (official API key)",
icon: "cloud",
iconBg: "bg-slate-500/10",
iconColor: "text-slate-600",
},
];
const STATUS_OPTIONS: { value: TaskStatus | "all"; labelKey: string }[] = [

View File

@@ -12,7 +12,7 @@ import { sanitizeErrorMessage } from "@omniroute/open-sse/utils/error";
const logger = pino({ name: "cloud-agents-credentials-api" });
const SaveCredentialSchema = z.object({
providerId: z.enum(["jules", "devin", "codex-cloud"]),
providerId: z.enum(["jules", "devin", "codex-cloud", "cursor-cloud"]),
apiKey: z.string().min(1),
baseUrl: z.string().url().optional(),
});

View File

@@ -11,6 +11,7 @@ const PROVIDER_NAMES: Record<string, string> = {
jules: "Jules",
devin: "Devin",
"codex-cloud": "Codex Cloud",
"cursor-cloud": "Cursor Cloud",
};
interface ProviderHealth {

View File

@@ -0,0 +1,205 @@
import {
CloudAgentBase,
type AgentCredentials,
type CreateTaskParams,
type GetStatusResult,
} from "../baseAgent.ts";
import type { CloudAgentActivity, CloudAgentStatus, CloudAgentTask } from "../types.ts";
import { CLOUD_AGENT_STATUS } from "../types.ts";
/**
* Cursor Cloud Agent — drives Cursor's Background / Cloud Agents through its official
* REST API (api.cursor.com) authenticated with a user or service-account API key.
*
* This is the API-key path requested in #4227 as a safer alternative to re-using the
* Cursor IDE's OAuth session (provider `cursor`, which carries a ban-risk warning).
* Like Devin and Jules it is a plain REST adapter — it does NOT pull in the
* `@cursor/sdk` package (which ships per-platform native binaries); Cursor's SDK is
* itself a thin wrapper over this same REST API.
*
* NOTE: the endpoint paths and request/response field names follow Cursor's documented
* Cloud Agents API (v0). They are pending a live validation run against a real Cursor
* API key before this is merged (Rule #18 — external-API integration, see PR notes).
* `baseUrl` is overridable per-credential so the version/path can be corrected without
* a code change.
*/
const CURSOR_DEFAULT_BASE_URL = "https://api.cursor.com/v0";
// Cursor returns UPPERCASE status enums that the base `mapStatus()` substring matcher
// does not recognize (e.g. FINISHED would fall through to "queued"). Map explicitly,
// falling back to the base matcher for anything unforeseen.
const CURSOR_STATUS_MAP: Record<string, CloudAgentStatus> = {
CREATING: CLOUD_AGENT_STATUS.QUEUED,
PENDING: CLOUD_AGENT_STATUS.QUEUED,
QUEUED: CLOUD_AGENT_STATUS.QUEUED,
RUNNING: CLOUD_AGENT_STATUS.RUNNING,
FINISHED: CLOUD_AGENT_STATUS.COMPLETED,
COMPLETED: CLOUD_AGENT_STATUS.COMPLETED,
ERROR: CLOUD_AGENT_STATUS.FAILED,
FAILED: CLOUD_AGENT_STATUS.FAILED,
CANCELLED: CLOUD_AGENT_STATUS.CANCELLED,
EXPIRED: CLOUD_AGENT_STATUS.FAILED,
};
export class CursorCloudAgent extends CloudAgentBase {
readonly providerId = "cursor-cloud";
readonly baseUrl = CURSOR_DEFAULT_BASE_URL;
private resolveBaseUrl(credentials: AgentCredentials): string {
return (credentials.baseUrl || this.baseUrl).replace(/\/$/, "");
}
private mapCursorStatus(raw: string | undefined | null): CloudAgentStatus {
if (!raw) return CLOUD_AGENT_STATUS.QUEUED;
return CURSOR_STATUS_MAP[raw.toUpperCase()] ?? this.mapStatus(raw);
}
private authHeaders(credentials: AgentCredentials, withBody = false): Record<string, string> {
const headers: Record<string, string> = {
Authorization: `Bearer ${credentials.apiKey}`,
};
if (withBody) headers["Content-Type"] = "application/json";
return headers;
}
async createTask(
params: CreateTaskParams,
credentials: AgentCredentials
): Promise<CloudAgentTask> {
const taskId = this.generateTaskId();
const source: Record<string, unknown> = { repository: params.source.repoUrl };
if (params.source.branch) source.ref = params.source.branch;
const body: Record<string, unknown> = {
prompt: { text: params.prompt },
source,
};
if (params.options.autoCreatePr) body.autoCreatePr = true;
const response = await fetch(`${this.resolveBaseUrl(credentials)}/agents`, {
method: "POST",
headers: this.authHeaders(credentials, true),
body: JSON.stringify(body),
});
if (!response.ok) {
const error = await response.text();
throw new Error(`Cursor create agent failed: ${response.status} ${error}`);
}
const data = await response.json();
return {
id: taskId,
providerId: this.providerId,
externalId: data.id,
status: this.mapCursorStatus(data.status),
prompt: params.prompt,
source: params.source,
options: params.options,
activities: [],
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
};
}
async getStatus(externalId: string, credentials: AgentCredentials): Promise<GetStatusResult> {
const response = await fetch(
`${this.resolveBaseUrl(credentials)}/agents/${encodeURIComponent(externalId)}`,
{ headers: this.authHeaders(credentials) }
);
if (!response.ok) {
const error = await response.text();
throw new Error(`Cursor get agent failed: ${response.status} ${error}`);
}
const data = await response.json();
const status = this.mapCursorStatus(data.status);
const conversation = Array.isArray(data.conversation) ? data.conversation : [];
const activities: CloudAgentActivity[] = conversation.map((msg: Record<string, unknown>) => ({
id: this.generateActivityId(),
type: "message" as const,
content: typeof msg.text === "string" ? msg.text : "",
timestamp: (msg.createdAt as string) || new Date().toISOString(),
}));
let result;
if (status === CLOUD_AGENT_STATUS.COMPLETED) {
const target = (data.target as Record<string, unknown>) || {};
result = {
prUrl: (target.prUrl as string) || (target.url as string) || undefined,
summary: typeof data.summary === "string" ? data.summary : undefined,
};
}
return {
status,
externalId,
result,
activities,
error: typeof data.error === "string" ? data.error : undefined,
};
}
async approvePlan(_externalId: string, _credentials: AgentCredentials): Promise<void> {
throw new Error("Cursor Cloud Agents run autonomously and do not support plan approval");
}
async sendMessage(
externalId: string,
message: string,
credentials: AgentCredentials
): Promise<CloudAgentActivity> {
const response = await fetch(
`${this.resolveBaseUrl(credentials)}/agents/${encodeURIComponent(externalId)}/followup`,
{
method: "POST",
headers: this.authHeaders(credentials, true),
body: JSON.stringify({ prompt: { text: message } }),
}
);
if (!response.ok) {
const error = await response.text();
throw new Error(`Cursor follow-up failed: ${response.status} ${error}`);
}
return {
id: this.generateActivityId(),
type: "message",
content: message,
timestamp: new Date().toISOString(),
};
}
async listSources(
credentials: AgentCredentials
): Promise<{ name: string; url: string; branch?: string }[]> {
const response = await fetch(`${this.resolveBaseUrl(credentials)}/repositories`, {
headers: this.authHeaders(credentials),
});
if (!response.ok) return [];
const data = await response.json();
const repos = Array.isArray(data?.repositories)
? data.repositories
: Array.isArray(data)
? data
: [];
return repos
.map((repo: Record<string, unknown>) => {
const url = (repo.url as string) || (repo.repository as string) || "";
if (!url) return null;
const name = (repo.name as string) || url.split("/").slice(-2).join("/");
return { name, url };
})
.filter((entry: { name: string; url: string } | null): entry is { name: string; url: string } =>
entry !== null
);
}
}

View File

@@ -2,11 +2,15 @@ import type { CloudAgentBase } from "./baseAgent.ts";
import { JulesAgent } from "./agents/jules.ts";
import { DevinAgent } from "./agents/devin.ts";
import { CodexCloudAgent } from "./agents/codex.ts";
import { CursorCloudAgent } from "./agents/cursor.ts";
const AGENTS: Record<string, CloudAgentBase> = {
jules: new JulesAgent(),
devin: new DevinAgent(),
"codex-cloud": new CodexCloudAgent(),
// #4227: Cursor Background/Cloud Agents via the official REST API (API-key based,
// no IDE-OAuth ban risk). Distinct provider id from the OAuth chat provider `cursor`.
"cursor-cloud": new CursorCloudAgent(),
};
export function getAgent(providerId: string): CloudAgentBase | null {
@@ -21,5 +25,5 @@ export function isCloudAgentProvider(providerId: string): boolean {
return providerId in AGENTS;
}
export { JulesAgent, DevinAgent, CodexCloudAgent };
export { JulesAgent, DevinAgent, CodexCloudAgent, CursorCloudAgent };
export type { CloudAgentBase } from "./baseAgent.ts";

View File

@@ -46,7 +46,7 @@ export interface CloudAgentActivity {
export interface CloudAgentTask {
id: string;
providerId: "jules" | "devin" | "codex-cloud";
providerId: "jules" | "devin" | "codex-cloud" | "cursor-cloud";
externalId?: string;
status: CloudAgentStatus;
prompt: string;
@@ -95,7 +95,7 @@ export const CloudAgentTaskOptionsSchema = z.object({
});
export const CreateCloudAgentTaskSchema = z.object({
providerId: z.enum(["jules", "devin", "codex-cloud"]),
providerId: z.enum(["jules", "devin", "codex-cloud", "cursor-cloud"]),
prompt: z.string().min(1).max(10000),
source: CloudAgentSourceSchema,
options: CloudAgentTaskOptionsSchema.optional(),

View File

@@ -331,6 +331,7 @@ const LOBE_PROVIDER_ALIASES = {
copilot: "GithubCopilot",
coze: "Coze",
cursor: "Cursor",
"cursor-cloud": "Cursor",
databricks: "Dbrx",
deepinfra: "DeepInfra",
deepseek: "DeepSeek",

View File

@@ -0,0 +1,149 @@
/**
* #4227 — Cursor Cloud Agent (REST adapter).
*
* Validates the adapter mapping between Cursor's Background/Cloud Agents REST API and
* OmniRoute's CloudAgentBase contract (status mapping, request shape, result extraction)
* with a mocked fetch. NOTE: this proves the adapter's internal mapping, NOT the live
* Cursor API shapes — those need a live validation run with a real Cursor API key before
* merge (Rule #18, external-API integration; see the PR description).
*/
import test from "node:test";
import assert from "node:assert/strict";
const cursorMod = await import("../../src/lib/cloudAgent/agents/cursor.ts");
const registry = await import("../../src/lib/cloudAgent/registry.ts");
const CREDS = { apiKey: "key_test_123" };
const SOURCE = { repoName: "org/repo", repoUrl: "https://github.com/org/repo", branch: "main" };
const OPTIONS = { autoCreatePr: true };
function mockFetch(handler: (url: string, init?: RequestInit) => Response | Promise<Response>) {
const original = globalThis.fetch;
// @ts-expect-error test shim
globalThis.fetch = async (url: string | URL | Request, init?: RequestInit) =>
handler(String(url), init);
return () => {
globalThis.fetch = original;
};
}
test("#4227 registry exposes cursor-cloud as a cloud-agent provider", () => {
assert.equal(registry.isCloudAgentProvider("cursor-cloud"), true);
const agent = registry.getAgent("cursor-cloud");
assert.ok(agent, "getAgent('cursor-cloud') returns an instance");
assert.equal(agent?.providerId, "cursor-cloud");
assert.ok(registry.getAvailableAgents().includes("cursor-cloud"));
});
test("#4227 createTask posts the prompt+repo and maps CREATING → queued", async () => {
const agent = new cursorMod.CursorCloudAgent();
let captured: { url: string; body: any } | null = null;
const restore = mockFetch((url, init) => {
captured = { url, body: init?.body ? JSON.parse(String(init.body)) : null };
return Response.json({ id: "bc-abc123", status: "CREATING", name: "agent-1" });
});
try {
const task = await agent.createTask({ prompt: "fix the bug", source: SOURCE, options: OPTIONS }, CREDS);
assert.equal(task.providerId, "cursor-cloud");
assert.equal(task.externalId, "bc-abc123");
assert.equal(task.status, "queued");
assert.equal(task.prompt, "fix the bug");
// request shape
assert.ok(captured, "fetch was called");
assert.match(captured!.url, /\/agents$/);
assert.equal(captured!.body.prompt.text, "fix the bug");
assert.equal(captured!.body.source.repository, "https://github.com/org/repo");
assert.equal(captured!.body.source.ref, "main");
assert.equal(captured!.body.autoCreatePr, true);
} finally {
restore();
}
});
test("#4227 createTask surfaces an upstream error instead of swallowing it", async () => {
const agent = new cursorMod.CursorCloudAgent();
const restore = mockFetch(() => new Response("nope", { status: 401 }));
try {
await assert.rejects(
agent.createTask({ prompt: "x", source: SOURCE, options: {} }, CREDS),
/Cursor create agent failed: 401/
);
} finally {
restore();
}
});
test("#4227 getStatus maps FINISHED → completed and extracts the PR url + conversation", async () => {
const agent = new cursorMod.CursorCloudAgent();
const restore = mockFetch(() =>
Response.json({
id: "bc-abc123",
status: "FINISHED",
target: { prUrl: "https://github.com/org/repo/pull/7", branchName: "cursor/fix" },
summary: "Fixed it",
conversation: [{ type: "assistant_message", text: "done", createdAt: "2026-06-19T00:00:00Z" }],
})
);
try {
const result = await agent.getStatus("bc-abc123", CREDS);
assert.equal(result.status, "completed");
assert.equal(result.result?.prUrl, "https://github.com/org/repo/pull/7");
assert.equal(result.result?.summary, "Fixed it");
assert.equal(result.activities.length, 1);
assert.equal(result.activities[0].content, "done");
} finally {
restore();
}
});
test("#4227 getStatus maps Cursor enums (RUNNING→running, ERROR→failed)", async () => {
const agent = new cursorMod.CursorCloudAgent();
let restore = mockFetch(() => Response.json({ status: "RUNNING" }));
try {
assert.equal((await agent.getStatus("id", CREDS)).status, "running");
} finally {
restore();
}
restore = mockFetch(() => Response.json({ status: "ERROR", error: "boom" }));
try {
const r = await agent.getStatus("id", CREDS);
assert.equal(r.status, "failed");
assert.equal(r.error, "boom");
} finally {
restore();
}
});
test("#4227 sendMessage posts a followup; approvePlan is unsupported", async () => {
const agent = new cursorMod.CursorCloudAgent();
let captured: { url: string; body: any } | null = null;
const restore = mockFetch((url, init) => {
captured = { url, body: init?.body ? JSON.parse(String(init.body)) : null };
return Response.json({ ok: true });
});
try {
const activity = await agent.sendMessage("bc-1", "also add tests", CREDS);
assert.equal(activity.type, "message");
assert.equal(activity.content, "also add tests");
assert.match(captured!.url, /\/agents\/bc-1\/followup$/);
assert.equal(captured!.body.prompt.text, "also add tests");
} finally {
restore();
}
await assert.rejects(agent.approvePlan("bc-1", CREDS), /do not support plan approval/);
});
test("#4227 listSources normalizes the repositories list", async () => {
const agent = new cursorMod.CursorCloudAgent();
const restore = mockFetch(() =>
Response.json({ repositories: [{ url: "https://github.com/org/repo", name: "org/repo" }] })
);
try {
const sources = await agent.listSources(CREDS);
assert.equal(sources.length, 1);
assert.equal(sources[0].url, "https://github.com/org/repo");
assert.equal(sources[0].name, "org/repo");
} finally {
restore();
}
});

View File

@@ -3,9 +3,9 @@ import { describe, test } from "node:test";
import { getAvailableAgents } from "../../src/lib/cloudAgent/registry.ts";
describe("cloud-agent health API — getAvailableAgents", () => {
test("returns exactly three agents", () => {
test("returns exactly four agents", () => {
const agents = getAvailableAgents();
assert.equal(agents.length, 3);
assert.equal(agents.length, 4);
});
test('includes "jules"', () => {
@@ -20,8 +20,12 @@ describe("cloud-agent health API — getAvailableAgents", () => {
assert.ok(getAvailableAgents().includes("codex-cloud"));
});
test('includes "cursor-cloud"', () => {
assert.ok(getAvailableAgents().includes("cursor-cloud"));
});
test("returns agents in expected order", () => {
assert.deepEqual(getAvailableAgents(), ["jules", "devin", "codex-cloud"]);
assert.deepEqual(getAvailableAgents(), ["jules", "devin", "codex-cloud", "cursor-cloud"]);
});
});
@@ -64,11 +68,13 @@ describe("cloud-agent health API — health check logic", () => {
jules: "Jules",
devin: "Devin",
"codex-cloud": "Codex Cloud",
"cursor-cloud": "Cursor Cloud",
};
assert.equal(PROVIDER_NAMES["jules"], "Jules");
assert.equal(PROVIDER_NAMES["devin"], "Devin");
assert.equal(PROVIDER_NAMES["codex-cloud"], "Codex Cloud");
assert.equal(PROVIDER_NAMES["cursor-cloud"], "Cursor Cloud");
assert.equal(PROVIDER_NAMES["nonexistent"], undefined);
});
});