mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-07-31 04:12:10 +03:00
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:
committed by
GitHub
parent
3bc1a0f6de
commit
1079fc36f0
@@ -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)
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
};
|
||||
|
||||
// ───────────────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -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 }[] = [
|
||||
|
||||
@@ -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(),
|
||||
});
|
||||
|
||||
@@ -11,6 +11,7 @@ const PROVIDER_NAMES: Record<string, string> = {
|
||||
jules: "Jules",
|
||||
devin: "Devin",
|
||||
"codex-cloud": "Codex Cloud",
|
||||
"cursor-cloud": "Cursor Cloud",
|
||||
};
|
||||
|
||||
interface ProviderHealth {
|
||||
|
||||
205
src/lib/cloudAgent/agents/cursor.ts
Normal file
205
src/lib/cloudAgent/agents/cursor.ts
Normal 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
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -331,6 +331,7 @@ const LOBE_PROVIDER_ALIASES = {
|
||||
copilot: "GithubCopilot",
|
||||
coze: "Coze",
|
||||
cursor: "Cursor",
|
||||
"cursor-cloud": "Cursor",
|
||||
databricks: "Dbrx",
|
||||
deepinfra: "DeepInfra",
|
||||
deepseek: "DeepSeek",
|
||||
|
||||
149
tests/unit/cloud-agent-cursor-4227.test.ts
Normal file
149
tests/unit/cloud-agent-cursor-4227.test.ts
Normal 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();
|
||||
}
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user