Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 36 additions & 2 deletions src/adapters/cursor/live-models.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@ import { GetUsableModelsResponseSchema } from "./gen/agent_pb";
const CURSOR_GET_USABLE_MODELS_PATH = "/agent.v1.AgentService/GetUsableModels";
const CURSOR_DISCOVERY_CLIENT_VERSION = "cli-2026.02.13-41ac335";
const CURSOR_MODEL_DISCOVERY_MAX_BYTES = 4 * 1024 * 1024;
type CursorUsableModelsFetcher = (opts: CursorUsableModelsOptions) => Promise<CursorUsableModelsResult>;
let cursorUsableModelsFetcherForTests: CursorUsableModelsFetcher | null = null;

export interface CursorUsableModelsOptions {
apiKey: string;
Expand All @@ -31,6 +33,11 @@ export type CursorUsableModelsResult =
| { ok: true; models: string[] }
| { ok: false; error: "auth" | "http" | "transport" | "timeout" | "decode" | "empty" | "too_large"; detail?: string };

/** Test-only seam for management connectivity probes; production callers retain the HTTP/2 path. */
export function setFetchCursorUsableModelsForTests(next: CursorUsableModelsFetcher | null): void {
cursorUsableModelsFetcherForTests = next;
}

const RETRYABLE_DISCOVERY_ERRORS = new Set(["timeout", "transport"]);
const DISCOVERY_RETRY_TIMEOUT_MS = 3_000;

Expand All @@ -42,10 +49,37 @@ const DISCOVERY_RETRY_TIMEOUT_MS = 3_000;
* devlog 260723_cursor_context_continuity/030).
*/
export async function fetchCursorUsableModels(opts: CursorUsableModelsOptions): Promise<CursorUsableModelsResult> {
const first = await fetchCursorUsableModelsOnce(opts);
if (cursorUsableModelsFetcherForTests) return cursorUsableModelsFetcherForTests(opts);
const resolved = resolveCursorDiscoveryBaseUrl(opts.baseUrl ?? "https://api2.cursor.sh");
if (!resolved.ok) return resolved;
const first = await fetchCursorUsableModelsOnce({ ...opts, baseUrl: resolved.baseUrl });
if (first.ok || !RETRYABLE_DISCOVERY_ERRORS.has(first.error)) return first;
await new Promise(resolve => setTimeout(resolve, 250 + Math.floor(Math.random() * 250)));
return fetchCursorUsableModelsOnce({ ...opts, timeoutMs: Math.min(opts.timeoutMs ?? 8000, DISCOVERY_RETRY_TIMEOUT_MS) });
return fetchCursorUsableModelsOnce({
...opts,
baseUrl: resolved.baseUrl,
timeoutMs: Math.min(opts.timeoutMs ?? 8000, DISCOVERY_RETRY_TIMEOUT_MS),
});
}

function resolveCursorDiscoveryBaseUrl(raw: string): { ok: true; baseUrl: string } | Extract<CursorUsableModelsResult, { ok: false }> {
const baseUrl = raw.replace(/\/+$/, "");
let parsed: URL;
try {
parsed = new URL(baseUrl);
} catch {
return { ok: false, error: "transport", detail: "Cursor discovery URL is invalid" };
}
if (parsed.protocol === "https:") return { ok: true, baseUrl };
// Local h2c fixtures (and an operator loopback proxy) never leave the machine.
// Anything else with a Bearer token must be HTTPS, matching providerOutbound POST.
if (parsed.protocol === "http:") {
const host = parsed.hostname.replace(/^\[|\]$/g, "").toLowerCase();
if (host === "127.0.0.1" || host === "::1" || host === "localhost" || host.endsWith(".localhost")) {
return { ok: true, baseUrl };
}
}
return { ok: false, error: "transport", detail: "Cursor discovery URL must use HTTPS" };
}

async function fetchCursorUsableModelsOnce(opts: CursorUsableModelsOptions): Promise<CursorUsableModelsResult> {
Expand Down
27 changes: 26 additions & 1 deletion src/adapters/cursor/live-transport.ts
Original file line number Diff line number Diff line change
Expand Up @@ -428,6 +428,7 @@ class LiveCursorTransport implements CursorTransport {
// close; safe to read after a stream failure because open() owns the only writer before run().
private turnStartedAt = 0;
private framesReceived = 0;
private sawAssistantText = false;
private firstFrameAt?: number;
private firstFrameLogged = false;
/** Stable session identifier sent as x-session-id; mirrors IDE session semantics. */
Expand Down Expand Up @@ -769,6 +770,7 @@ class LiveCursorTransport implements CursorTransport {
): void {
this.turnStartedAt = Date.now();
this.framesReceived = 0;
this.sawAssistantText = false;
this.firstFrameAt = undefined;
this.firstFrameLogged = false;
const dialHost = cursorHostLabel(this.input.provider.baseUrl || "https://api2.cursor.sh");
Expand Down Expand Up @@ -1026,6 +1028,25 @@ class LiveCursorTransport implements CursorTransport {
settler.settleFail(new Error("Cursor stream ended before any response frame (unexpected EOF)"));
return;
}
if (state.terminated || this.expectedClose) {
releaseBacklogLease();
settler.settleFinish();
return;
}
// Open tools fail-closed as a truncation *event* (finalizeTurnEvents), not a thrown
// transport error. settleFail here would hide that typed message as adapter_eof.
if (state.openToolCalls.size > 0) {
for (const event of finalizeTurnEvents(state)) push(event);
releaseBacklogLease();
settler.settleFinish();
return;
}
if (this.framesReceived > 0 && this.sawAssistantText) {
for (const event of finalizeTurnEvents(state)) push(event);
releaseBacklogLease();
settler.settleFinish();
return;
}
releaseBacklogLease();
settler.settleFinish();
}, (err) => {
Expand Down Expand Up @@ -1087,7 +1108,10 @@ class LiveCursorTransport implements CursorTransport {
debugProviderDiagnostic("cursor", "interaction-query", { id: query.id, queryCase: query.query.case ?? "unknown", reply: plan.replyCase });
this.stream.write(encodeClientMessage({ message: { case: "interactionResponse", value: plan.response } }));
if (!state.terminated) {
if (plan.planText) push({ type: "text", text: plan.planText });
if (plan.planText) {
this.sawAssistantText = true;
push({ type: "text", text: plan.planText });
}
push({ type: "heartbeat" });
}
return;
Expand All @@ -1100,6 +1124,7 @@ class LiveCursorTransport implements CursorTransport {
const awaitedNativeArgsBeforeMapping = update?.case === "toolCallCompleted"
&& state.openToolCalls.get(update.value.callId)?.awaitingNativeArgs === true;
const mapped = mapCursorProtobufServerMessage(message, state);
if (mapped.some(event => event.type === "text")) this.sawAssistantText = true;
Comment thread
coderabbitai[bot] marked this conversation as resolved.
const beganAwaitingNativeClientToolArgs = update?.case === "toolCallCompleted"
&& !awaitedNativeArgsBeforeMapping
&& state.openToolCalls.get(update.value.callId)?.awaitingNativeArgs === true;
Expand Down
7 changes: 6 additions & 1 deletion src/adapters/cursor/tool-definitions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -525,7 +525,12 @@ export function nonEmptyShellBridgeCommandFromArgs(
}
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return undefined;
const record = parsed as Record<string, unknown>;
for (const key of shellBridgeRequiredCommandKeys(toolName, schema)) {
const requiredKeys = shellBridgeRequiredCommandKeys(toolName, schema);
const candidateKeys = new Set<"cmd" | "command">([
...requiredKeys,
requiredKeys.includes("cmd") ? "command" : "cmd",
]);
for (const key of candidateKeys) {
const value = record[key];
if (typeof value === "string" && value.trim().length > 0) return value.trim();
}
Expand Down
4 changes: 4 additions & 0 deletions src/responses/parser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -171,6 +171,10 @@ function buildTools(tools: unknown[] | undefined): OcxTool[] | undefined {
};
for (const t of tools) {
if (!isObj(t)) continue;
if (t.type === "function" && isObj(t.function) && typeof t.function.name === "string" && t.function.name.length > 0) {
pushFn(t.function as Record<string, unknown>);
continue;
}
if (t.type === "function" && typeof t.name === "string") {
pushFn(t);
} else if (t.type === "namespace" && Array.isArray(t.tools)) {
Expand Down
22 changes: 22 additions & 0 deletions src/server/management/provider-routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ import { replaceProviderAccountSet } from "../../oauth/store";
import { providerDestinationResolvedError } from "../../lib/destination-policy";
import { reconcileLiveStateStores } from "../../lib/state-store-registrations";
import { ProviderOutboundPolicyError, providerOutboundGet, providerOutboundPost, providerRedirectError } from "../../lib/provider-outbound";
import { fetchCursorUsableModels } from "../../adapters/cursor/live-models";
import { parseAntigravityAvailableModels } from "../../providers/antigravity-models";
import { enrichProviderFromCatalog, listKeyLoginProviders } from "../../oauth/key-providers";
import { deriveProviderPresets } from "../../providers/derive";
Expand Down Expand Up @@ -732,6 +733,27 @@ export async function handleProviderRoutes(ctx: ManagementContext): Promise<Resp
if (prov.authMode === "oauth" && !apiKey) {
return jsonResponse({ ok: false, latencyMs: 0, error: "static catalog only — upstream not verified (not logged in)" });
}
if (prov.adapter === "cursor") {
const started = Date.now();
const live = await fetchCursorUsableModels({
apiKey: apiKey ?? "",
baseUrl: prov.baseUrl,
});
Comment thread
coderabbitai[bot] marked this conversation as resolved.
const latencyMs = Date.now() - started;
if (!live.ok) {
return jsonResponse({
ok: false,
latencyMs,
error: `cursor discovery ${live.error}${live.detail ? `: ${live.detail}` : ""}`,
});
}
return jsonResponse({
ok: true,
latencyMs,
models: live.models.length,
message: `Connected. ${live.models.length} models.`,
});
}
const project = prov.project ?? snapshot?.projectId;
if (antigravity && !project) {
return jsonResponse({ ok: false, latencyMs: 0, error: "Antigravity project unavailable — re-run `ocx login google-antigravity`" });
Expand Down
Loading
Loading