From 1079fc36f0ba9962829bf9e890092c2ea7ca73ec Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza <8016841+diegosouzapw@users.noreply.github.com> Date: Fri, 19 Jun 2026 02:21:59 -0300 Subject: [PATCH] feat(cloud-agent): Cursor Cloud Agent via official REST API key (#4227) (#4250) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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). --- CHANGELOG.md | 2 + config/quality/file-size-baseline.json | 3 +- docs/frameworks/CLOUD_AGENT.md | 17 +- scripts/check/check-known-symbols.ts | 3 + .../dashboard/cloud-agents/page.tsx | 9 + src/app/api/v1/agents/credentials/route.ts | 2 +- src/app/api/v1/agents/health/route.ts | 1 + src/lib/cloudAgent/agents/cursor.ts | 205 ++++++++++++++++++ src/lib/cloudAgent/registry.ts | 6 +- src/lib/cloudAgent/types.ts | 4 +- src/shared/components/lobeProviderIcons.ts | 1 + tests/unit/cloud-agent-cursor-4227.test.ts | 149 +++++++++++++ tests/unit/cloud-agent-health-api.test.ts | 12 +- 13 files changed, 403 insertions(+), 11 deletions(-) create mode 100644 src/lib/cloudAgent/agents/cursor.ts create mode 100644 tests/unit/cloud-agent-cursor-4227.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 20acb00201..5b5e0a99e7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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/:` 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/:` 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/:` 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/:` 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/:` suffixes + new categories follow). ([#4235](https://github.com/diegosouzapw/OmniRoute/issues/4235) — thanks @MRDGH2821) diff --git a/config/quality/file-size-baseline.json b/config/quality/file-size-baseline.json index 2b2cf8af46..35587e2679 100644 --- a/config/quality/file-size-baseline.json +++ b/config/quality/file-size-baseline.json @@ -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/: 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 ( **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 diff --git a/scripts/check/check-known-symbols.ts b/scripts/check/check-known-symbols.ts index f859136827..03404a8e0c 100644 --- a/scripts/check/check-known-symbols.ts +++ b/scripts/check/check-known-symbols.ts @@ -440,6 +440,9 @@ export function extractCloudAgentRegistryKeys(registrySource: string): Set = { codex: "codex-cloud", + // #4227: file agents/cursor.ts ↔ registry key "cursor-cloud" (distinct from the + // OAuth chat provider `cursor`). + cursor: "cursor-cloud", }; // ─────────────────────────────────────────────────────────────────────────── diff --git a/src/app/(dashboard)/dashboard/cloud-agents/page.tsx b/src/app/(dashboard)/dashboard/cloud-agents/page.tsx index ee68c9d9a9..98feffc574 100644 --- a/src/app/(dashboard)/dashboard/cloud-agents/page.tsx +++ b/src/app/(dashboard)/dashboard/cloud-agents/page.tsx @@ -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 }[] = [ diff --git a/src/app/api/v1/agents/credentials/route.ts b/src/app/api/v1/agents/credentials/route.ts index 9402a108b0..94356bbaae 100644 --- a/src/app/api/v1/agents/credentials/route.ts +++ b/src/app/api/v1/agents/credentials/route.ts @@ -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(), }); diff --git a/src/app/api/v1/agents/health/route.ts b/src/app/api/v1/agents/health/route.ts index d9ef8226f9..b533ab6734 100644 --- a/src/app/api/v1/agents/health/route.ts +++ b/src/app/api/v1/agents/health/route.ts @@ -11,6 +11,7 @@ const PROVIDER_NAMES: Record = { jules: "Jules", devin: "Devin", "codex-cloud": "Codex Cloud", + "cursor-cloud": "Cursor Cloud", }; interface ProviderHealth { diff --git a/src/lib/cloudAgent/agents/cursor.ts b/src/lib/cloudAgent/agents/cursor.ts new file mode 100644 index 0000000000..b35c033fb5 --- /dev/null +++ b/src/lib/cloudAgent/agents/cursor.ts @@ -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 = { + 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 { + const headers: Record = { + Authorization: `Bearer ${credentials.apiKey}`, + }; + if (withBody) headers["Content-Type"] = "application/json"; + return headers; + } + + async createTask( + params: CreateTaskParams, + credentials: AgentCredentials + ): Promise { + const taskId = this.generateTaskId(); + + const source: Record = { repository: params.source.repoUrl }; + if (params.source.branch) source.ref = params.source.branch; + const body: Record = { + 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 { + 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) => ({ + 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) || {}; + 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 { + throw new Error("Cursor Cloud Agents run autonomously and do not support plan approval"); + } + + async sendMessage( + externalId: string, + message: string, + credentials: AgentCredentials + ): Promise { + 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) => { + 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 + ); + } +} diff --git a/src/lib/cloudAgent/registry.ts b/src/lib/cloudAgent/registry.ts index 091359a400..bdaa2ebc94 100644 --- a/src/lib/cloudAgent/registry.ts +++ b/src/lib/cloudAgent/registry.ts @@ -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 = { 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"; diff --git a/src/lib/cloudAgent/types.ts b/src/lib/cloudAgent/types.ts index 45c7a65406..01d13fec22 100644 --- a/src/lib/cloudAgent/types.ts +++ b/src/lib/cloudAgent/types.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(), diff --git a/src/shared/components/lobeProviderIcons.ts b/src/shared/components/lobeProviderIcons.ts index 277ec56b4c..ea657321df 100644 --- a/src/shared/components/lobeProviderIcons.ts +++ b/src/shared/components/lobeProviderIcons.ts @@ -331,6 +331,7 @@ const LOBE_PROVIDER_ALIASES = { copilot: "GithubCopilot", coze: "Coze", cursor: "Cursor", + "cursor-cloud": "Cursor", databricks: "Dbrx", deepinfra: "DeepInfra", deepseek: "DeepSeek", diff --git a/tests/unit/cloud-agent-cursor-4227.test.ts b/tests/unit/cloud-agent-cursor-4227.test.ts new file mode 100644 index 0000000000..adc153c732 --- /dev/null +++ b/tests/unit/cloud-agent-cursor-4227.test.ts @@ -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) { + 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(); + } +}); diff --git a/tests/unit/cloud-agent-health-api.test.ts b/tests/unit/cloud-agent-health-api.test.ts index 2ad1293a24..327d5ad0a1 100644 --- a/tests/unit/cloud-agent-health-api.test.ts +++ b/tests/unit/cloud-agent-health-api.test.ts @@ -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); }); });