From a0e203c8687489129fb5149348fb3812b883f544 Mon Sep 17 00:00:00 2001 From: SB Yoon <44089734+yansigit@users.noreply.github.com> Date: Mon, 17 Aug 2026 00:13:42 -0600 Subject: [PATCH 1/8] fix(cursor): probe GetUsableModels in provider test --- src/adapters/cursor/live-models.ts | 8 ++++ src/server/management/provider-routes.ts | 17 +++++++ tests/provider-connection-test.test.ts | 57 ++++++++++++++++++++++++ 3 files changed, 82 insertions(+) diff --git a/src/adapters/cursor/live-models.ts b/src/adapters/cursor/live-models.ts index 423e36b49f..53431cc28f 100644 --- a/src/adapters/cursor/live-models.ts +++ b/src/adapters/cursor/live-models.ts @@ -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; +let cursorUsableModelsFetcherForTests: CursorUsableModelsFetcher | null = null; export interface CursorUsableModelsOptions { apiKey: string; @@ -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; @@ -42,6 +49,7 @@ const DISCOVERY_RETRY_TIMEOUT_MS = 3_000; * devlog 260723_cursor_context_continuity/030). */ export async function fetchCursorUsableModels(opts: CursorUsableModelsOptions): Promise { + if (cursorUsableModelsFetcherForTests) return cursorUsableModelsFetcherForTests(opts); const first = await fetchCursorUsableModelsOnce(opts); if (first.ok || !RETRYABLE_DISCOVERY_ERRORS.has(first.error)) return first; await new Promise(resolve => setTimeout(resolve, 250 + Math.floor(Math.random() * 250))); diff --git a/src/server/management/provider-routes.ts b/src/server/management/provider-routes.ts index 8ecae2c46b..674c3c6db4 100644 --- a/src/server/management/provider-routes.ts +++ b/src/server/management/provider-routes.ts @@ -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"; @@ -732,6 +733,22 @@ export async function handleProviderRoutes(ctx: ManagementContext): Promise { }); afterEach(() => { + setFetchCursorUsableModelsForTests(null); globalThis.fetch = originalFetch; if (previousHome === undefined) delete process.env.OPENCODEX_HOME; else process.env.OPENCODEX_HOME = previousHome; @@ -51,6 +53,61 @@ async function probe(config: OcxConfig, name: string): Promise<{ status: number; } describe("POST /api/providers/test (WP040 connectivity probe)", () => { + test("Cursor probes GetUsableModels and reports the live model count", async () => { + const calls: { apiKey: string; baseUrl?: string }[] = []; + setFetchCursorUsableModelsForTests(async options => { + calls.push({ apiKey: options.apiKey, baseUrl: options.baseUrl }); + return { ok: true, models: ["gpt-5.6-high", "claude-4.6-opus-high"] }; + }); + await saveCredential("cursor", { + access: "cursor-access-token", + refresh: "cursor-refresh-token", + expires: Date.now() + 3_600_000, + }); + const config = baseConfig({ + cursor: { ...structuredClone(OAUTH_PROVIDERS.cursor.providerConfig) }, + }); + + const { body } = await probe(config, "cursor"); + + expect(body).toMatchObject({ ok: true, message: "Connected. 2 models." }); + expect(calls).toEqual([{ apiKey: "cursor-access-token", baseUrl: "https://api2.cursor.sh" }]); + }); + + test("Cursor discovery failures are surfaced with their classification", async () => { + setFetchCursorUsableModelsForTests(async () => ({ ok: false, error: "http" })); + await saveCredential("cursor", { + access: "cursor-access-token", + refresh: "cursor-refresh-token", + expires: Date.now() + 3_600_000, + }); + const config = baseConfig({ + cursor: { ...structuredClone(OAUTH_PROVIDERS.cursor.providerConfig) }, + }); + + const { body } = await probe(config, "cursor"); + + expect(body.ok).toBe(false); + expect(body.error).toBe("cursor discovery http"); + }); + + test("disabled Cursor fails fast without probing discovery", async () => { + let probes = 0; + setFetchCursorUsableModelsForTests(async () => { + probes += 1; + return { ok: true, models: ["should-not-be-used"] }; + }); + const config = baseConfig({ + cursor: { ...structuredClone(OAUTH_PROVIDERS.cursor.providerConfig), disabled: true }, + }); + + const { body } = await probe(config, "cursor"); + + expect(body.ok).toBe(false); + expect(body.error).toBe("Provider is disabled"); + expect(probes).toBe(0); + }); + test("unreachable upstream reports ok:false with the failure reason", async () => { globalThis.fetch = (async () => { throw new TypeError("connection refused"); }) as typeof fetch; const config = baseConfig({ From 46646b6237a97ca6fb38272afcb819053fb3cad8 Mon Sep 17 00:00:00 2001 From: SB Yoon <44089734+yansigit@users.noreply.github.com> Date: Mon, 17 Aug 2026 00:17:37 -0600 Subject: [PATCH 2/8] fix(responses): unwrap Chat-shaped function tools in buildTools Co-authored-by: Cursor --- src/responses/parser.ts | 4 ++++ tests/responses-parser.test.ts | 30 ++++++++++++++++++++++++++++++ 2 files changed, 34 insertions(+) diff --git a/src/responses/parser.ts b/src/responses/parser.ts index 2f1dd73dd4..3a1dabe9cd 100644 --- a/src/responses/parser.ts +++ b/src/responses/parser.ts @@ -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") { + pushFn(t.function as Record); + continue; + } if (t.type === "function" && typeof t.name === "string") { pushFn(t); } else if (t.type === "namespace" && Array.isArray(t.tools)) { diff --git a/tests/responses-parser.test.ts b/tests/responses-parser.test.ts index 1db57b8024..7a48d4ac61 100644 --- a/tests/responses-parser.test.ts +++ b/tests/responses-parser.test.ts @@ -31,6 +31,36 @@ describe("Responses parser", () => { ]); }); + test("unwraps Chat-shaped function tools while retaining flat function tools", () => { + const parameters = { + type: "object", + properties: { zone: { type: "string" } }, + required: ["zone"], + }; + const nested = parseRequest({ + model: "test-model", + input: "What time is it?", + tools: [ + { + type: "function", + function: { name: "get_time", description: "t", parameters, strict: true }, + }, + ], + }); + expect(nested.context.tools).toEqual([ + { name: "get_time", description: "t", parameters, strict: true }, + ]); + + const flat = parseRequest({ + model: "test-model", + input: "What time is it?", + tools: [{ type: "function", name: "get_time", description: "t", parameters, strict: true }], + }); + expect(flat.context.tools).toEqual([ + { name: "get_time", description: "t", parameters, strict: true }, + ]); + }); + test("describes the exact apply_patch freeform envelope", () => { const parsed = parseRequest({ model: "xai/grok-4.5", From 14cd824ce8461760cc90f5cec930c5cb3d1ded30 Mon Sep 17 00:00:00 2001 From: SB Yoon <44089734+yansigit@users.noreply.github.com> Date: Mon, 17 Aug 2026 00:17:48 -0600 Subject: [PATCH 3/8] fix(cursor): accept cmd or command on shell-bridge extract Co-authored-by: Cursor --- src/adapters/cursor/tool-definitions.ts | 7 ++++++- tests/cursor-tool-arg-decoding.test.ts | 14 ++++++++------ 2 files changed, 14 insertions(+), 7 deletions(-) diff --git a/src/adapters/cursor/tool-definitions.ts b/src/adapters/cursor/tool-definitions.ts index 057399dacf..9f27be91ec 100644 --- a/src/adapters/cursor/tool-definitions.ts +++ b/src/adapters/cursor/tool-definitions.ts @@ -525,7 +525,12 @@ export function nonEmptyShellBridgeCommandFromArgs( } if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return undefined; const record = parsed as Record; - 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(); } diff --git a/tests/cursor-tool-arg-decoding.test.ts b/tests/cursor-tool-arg-decoding.test.ts index d3cf141a85..5980ad92e1 100644 --- a/tests/cursor-tool-arg-decoding.test.ts +++ b/tests/cursor-tool-arg-decoding.test.ts @@ -322,17 +322,18 @@ describe("Cursor Responses tool argument decoding", () => { expectDropped(state, callId, toolName); }); - test("exec_command rejects command-only payload when cmd is required", () => { + test("exec_command accepts command-only payload as a sibling to cmd", () => { const callId = "toolu_command_only"; const state = createCursorProtobufEventState({ clientToolNames: ["exec_command"] }); const args = shellBridgeArgs("exec_command", { command: jsonBytes("echo hi") }, callId); expect(mapSyntheticMcpExecToToolEvents(args, "fallback", { allowEmptyArgs: true, state })).toEqual([ - { type: "error", message: dropped("exec_command") }, + { type: "tool_call_start", id: callId, name: "exec_command" }, + { type: "tool_call_delta", arguments: "{\"command\":\"echo hi\"}" }, + { type: "tool_call_end", id: callId }, ]); - expectDropped(state, callId, "exec_command"); }); - test("exec_command rejects blank cmd even when command is present", () => { + test("exec_command accepts sibling command when cmd is blank", () => { const callId = "toolu_blank_cmd_with_command"; const state = createCursorProtobufEventState({ clientToolNames: ["exec_command"] }); const args = shellBridgeArgs("exec_command", { @@ -340,9 +341,10 @@ describe("Cursor Responses tool argument decoding", () => { command: jsonBytes("echo hi"), }, callId); expect(mapSyntheticMcpExecToToolEvents(args, "fallback", { allowEmptyArgs: true, state })).toEqual([ - { type: "error", message: dropped("exec_command") }, + { type: "tool_call_start", id: callId, name: "exec_command" }, + { type: "tool_call_delta", arguments: expect.stringContaining("\"command\":\"echo hi\"") }, + { type: "tool_call_end", id: callId }, ]); - expectDropped(state, callId, "exec_command"); }); test("stateless exec_command rejects empty args when allowEmptyArgs is enabled", () => { From 85495a36f234bd6a0ccf04011e4f0440f08dd6ce Mon Sep 17 00:00:00 2001 From: SB Yoon <44089734+yansigit@users.noreply.github.com> Date: Mon, 17 Aug 2026 00:23:11 -0600 Subject: [PATCH 4/8] test(cursor): align shell-bridge sibling-key expectations --- tests/cursor-tool-definitions.test.ts | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/tests/cursor-tool-definitions.test.ts b/tests/cursor-tool-definitions.test.ts index 45c674219b..65d5d635b1 100644 --- a/tests/cursor-tool-definitions.test.ts +++ b/tests/cursor-tool-definitions.test.ts @@ -157,8 +157,11 @@ describe("Cursor tool definitions", () => { }, } as OcxTool); expect(nonEmptyShellBridgeCommandFromArgs(JSON.stringify({ cmd: "echo hi" }), "exec_command", execSchema)).toBe("echo hi"); - expect(nonEmptyShellBridgeCommandFromArgs(JSON.stringify({ command: "echo hi" }), "exec_command", execSchema)).toBeUndefined(); - expect(nonEmptyShellBridgeCommandFromArgs(JSON.stringify({ cmd: "", command: "echo hi" }), "exec_command", execSchema)).toBeUndefined(); + expect(nonEmptyShellBridgeCommandFromArgs(JSON.stringify({ command: "echo hi" }), "exec_command", execSchema)).toBe("echo hi"); + expect(nonEmptyShellBridgeCommandFromArgs(JSON.stringify({ cmd: "", command: "echo hi" }), "exec_command", execSchema)).toBe("echo hi"); + expect(nonEmptyShellBridgeCommandFromArgs(JSON.stringify({ cmd: "exec", command: "shell" }), "exec_command", execSchema)).toBe("exec"); + expect(nonEmptyShellBridgeCommandFromArgs(JSON.stringify({}), "exec_command", execSchema)).toBeUndefined(); + expect(nonEmptyShellBridgeCommandFromArgs(JSON.stringify({ cmd: " ", command: "\t" }), "exec_command", execSchema)).toBeUndefined(); const shellSchema = cursorToolArgNormalizeSchema({ name: "shell_command", @@ -170,7 +173,11 @@ describe("Cursor tool definitions", () => { }, } as OcxTool); expect(nonEmptyShellBridgeCommandFromArgs(JSON.stringify({ command: "echo hi" }), "shell_command", shellSchema)).toBe("echo hi"); - expect(nonEmptyShellBridgeCommandFromArgs(JSON.stringify({ cmd: "echo hi" }), "shell_command", shellSchema)).toBeUndefined(); + expect(nonEmptyShellBridgeCommandFromArgs(JSON.stringify({ cmd: "echo hi" }), "shell_command", shellSchema)).toBe("echo hi"); + expect(nonEmptyShellBridgeCommandFromArgs(JSON.stringify({ command: "", cmd: "echo hi" }), "shell_command", shellSchema)).toBe("echo hi"); + expect(nonEmptyShellBridgeCommandFromArgs(JSON.stringify({ cmd: "exec", command: "shell" }), "shell_command", shellSchema)).toBe("shell"); + expect(nonEmptyShellBridgeCommandFromArgs(JSON.stringify({}), "shell_command", shellSchema)).toBeUndefined(); + expect(nonEmptyShellBridgeCommandFromArgs(JSON.stringify({ cmd: " ", command: "\t" }), "shell_command", shellSchema)).toBeUndefined(); }); test("does not alias namespaced exec_command tools", () => { From 6eb9955451f8a5de9177b7fe5b1ac76174dbb48a Mon Sep 17 00:00:00 2001 From: SB Yoon <44089734+yansigit@users.noreply.github.com> Date: Mon, 17 Aug 2026 00:23:46 -0600 Subject: [PATCH 5/8] fix(responses): drop empty nested function tool names Co-authored-by: Cursor --- src/responses/parser.ts | 2 +- tests/responses-parser.test.ts | 11 +++++++++++ 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/src/responses/parser.ts b/src/responses/parser.ts index 3a1dabe9cd..2acbe46eec 100644 --- a/src/responses/parser.ts +++ b/src/responses/parser.ts @@ -171,7 +171,7 @@ 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") { + if (t.type === "function" && isObj(t.function) && typeof t.function.name === "string" && t.function.name.length > 0) { pushFn(t.function as Record); continue; } diff --git a/tests/responses-parser.test.ts b/tests/responses-parser.test.ts index 7a48d4ac61..be6258f6a1 100644 --- a/tests/responses-parser.test.ts +++ b/tests/responses-parser.test.ts @@ -61,6 +61,17 @@ describe("Responses parser", () => { ]); }); + test("drops Chat-shaped function tools with an empty nested name", () => { + const parsed = parseRequest({ + model: "test-model", + input: "What time is it?", + tools: [{ type: "function", function: { name: "" } }], + }); + + expect(parsed.context.tools).toBeUndefined(); + expect(parsed.context.tools?.some(tool => tool.name.length === 0) ?? false).toBe(false); + }); + test("describes the exact apply_patch freeform envelope", () => { const parsed = parseRequest({ model: "xai/grok-4.5", From 6a64db19d9afc38957befd8e5da3a6545c39da59 Mon Sep 17 00:00:00 2001 From: SB Yoon <44089734+yansigit@users.noreply.github.com> Date: Mon, 17 Aug 2026 00:28:43 -0600 Subject: [PATCH 6/8] fix(cursor): synthesize done on clean EOF after assistant output --- src/adapters/cursor/live-transport.ts | 20 ++++++++++ tests/cursor-hardening.test.ts | 55 ++++++++++++++++++++++++++- 2 files changed, 74 insertions(+), 1 deletion(-) diff --git a/src/adapters/cursor/live-transport.ts b/src/adapters/cursor/live-transport.ts index afc411e243..dc51966cfa 100644 --- a/src/adapters/cursor/live-transport.ts +++ b/src/adapters/cursor/live-transport.ts @@ -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. */ @@ -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"); @@ -1026,6 +1028,23 @@ 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; + } + 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) => { @@ -1100,6 +1119,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; const beganAwaitingNativeClientToolArgs = update?.case === "toolCallCompleted" && !awaitedNativeArgsBeforeMapping && state.openToolCalls.get(update.value.callId)?.awaitingNativeArgs === true; diff --git a/tests/cursor-hardening.test.ts b/tests/cursor-hardening.test.ts index c7bf93b959..2526557360 100644 --- a/tests/cursor-hardening.test.ts +++ b/tests/cursor-hardening.test.ts @@ -4,9 +4,12 @@ import { describe, expect, spyOn, test } from "bun:test"; import { AgentServerMessageSchema, GetUsableModelsResponseSchema, + KvServerMessageSchema, ModelDetailsSchema, + TextDeltaUpdateSchema, + InteractionUpdateSchema, } from "../src/adapters/cursor/gen/agent_pb"; -import { encodeConnectFrame } from "../src/adapters/cursor/framing"; +import { CONNECT_FLAG_END_STREAM, encodeConnectFrame } from "../src/adapters/cursor/framing"; import { fetchCursorUsableModels } from "../src/adapters/cursor/live-models"; import { armTimeoutDestroyFallback, createLiveCursorTransport, createTerminalSettler } from "../src/adapters/cursor/live-transport"; import { createTestTranslatorBudget } from "./helpers/translator-budget"; @@ -371,6 +374,56 @@ describe("Cursor timeout destroy fallback", () => { }); describe("Cursor live transport unexpected EOF", () => { + test("synthesizes done after assistant text on clean Connect EOF without turnEnded", async () => { + const textFrame = encodeConnectFrame(toBinary(AgentServerMessageSchema, create(AgentServerMessageSchema, { + message: { + case: "interactionUpdate", + value: create(InteractionUpdateSchema, { + message: { + case: "textDelta", + value: create(TextDeltaUpdateSchema, { text: "hello" }), + }, + }), + }, + }))); + const kvFrame = encodeConnectFrame(toBinary(AgentServerMessageSchema, create(AgentServerMessageSchema, { + message: { + case: "kvServerMessage", + value: create(KvServerMessageSchema, { id: 7 }), + }, + }))); + const connectEnd = encodeConnectFrame(new TextEncoder().encode("{}"), { + flags: CONNECT_FLAG_END_STREAM, + }); + + await withDiscoveryServer(stream => { + stream.respond({ ":status": 200, "content-type": "application/connect+proto" }); + stream.end(Buffer.from(new Uint8Array([...textFrame, ...kvFrame, ...connectEnd]))); + }, async baseUrl => { + const transport = createLiveCursorTransport({ + provider: { adapter: "cursor", baseUrl, apiKey: "test-token" }, + translatorBudget: createTestTranslatorBudget(), + firstFrameTimeoutMs: 2_000, + }); + const messages: Array<{ type: string }> = []; + try { + for await (const message of transport.run({ + modelId: "composer-2", + conversationId: "cursor_clean_eof_test", + system: [], + messages: [{ role: "user", content: "hello" }], + })) { + messages.push(message); + } + } finally { + await transport.close?.(); + } + + expect(messages).toContainEqual({ type: "text", text: "hello" }); + expect(messages.at(-1)).toMatchObject({ type: "done" }); + }); + }); + test("zero-frame stream end surfaces as a transport error, not success", async () => { // Real h2c peer that accepts the request stream and immediately ends it with no // response frames — the shape the WP4 reviewer reproduced as a silent success. From 08eb65d1f3b706b04af4c759c8fe6be24df4c496 Mon Sep 17 00:00:00 2001 From: SB Yoon <44089734+yansigit@users.noreply.github.com> Date: Mon, 17 Aug 2026 01:16:58 -0600 Subject: [PATCH 7/8] fix(cursor): address CodeRabbit findings on EOF, HTTPS, and probe shape Count createPlanRequestQuery text as assistant output before clean EOF, reject non-loopback http discovery URLs before sending the Bearer token, and return the live model count as structured data on the Cursor probe. --- src/adapters/cursor/live-models.ts | 30 ++++++++++- src/adapters/cursor/live-transport.ts | 5 +- src/server/management/provider-routes.ts | 7 ++- tests/cursor-hardening.test.ts | 67 ++++++++++++++++++++++++ tests/provider-connection-test.test.ts | 2 +- 5 files changed, 106 insertions(+), 5 deletions(-) diff --git a/src/adapters/cursor/live-models.ts b/src/adapters/cursor/live-models.ts index 53431cc28f..00831f84c2 100644 --- a/src/adapters/cursor/live-models.ts +++ b/src/adapters/cursor/live-models.ts @@ -50,10 +50,36 @@ const DISCOVERY_RETRY_TIMEOUT_MS = 3_000; */ export async function fetchCursorUsableModels(opts: CursorUsableModelsOptions): Promise { if (cursorUsableModelsFetcherForTests) return cursorUsableModelsFetcherForTests(opts); - const first = await fetchCursorUsableModelsOnce(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 { + 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 { diff --git a/src/adapters/cursor/live-transport.ts b/src/adapters/cursor/live-transport.ts index dc51966cfa..c5203106d7 100644 --- a/src/adapters/cursor/live-transport.ts +++ b/src/adapters/cursor/live-transport.ts @@ -1106,7 +1106,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; diff --git a/src/server/management/provider-routes.ts b/src/server/management/provider-routes.ts index 674c3c6db4..702f4c853d 100644 --- a/src/server/management/provider-routes.ts +++ b/src/server/management/provider-routes.ts @@ -747,7 +747,12 @@ export async function handleProviderRoutes(ctx: ManagementContext): Promise { expect(result).toEqual({ ok: true, models: ["gpt-5.5-high"] }); }); + test("rejects a cleartext non-loopback discovery URL before connecting", async () => { + const result = await fetchCursorUsableModels({ + apiKey: "test-token", + baseUrl: "http://api2.cursor.sh", + }); + + expect(result).toEqual({ + ok: false, + error: "transport", + detail: "Cursor discovery URL must use HTTPS", + }); + }); + test("classifies authentication failures", async () => { const result = await withDiscoveryServer(respond(401), baseUrl => fetchCursorUsableModels({ apiKey: "bad-token", baseUrl })); @@ -424,6 +440,57 @@ describe("Cursor live transport unexpected EOF", () => { }); }); + test("synthesizes done after createPlanRequestQuery text on clean Connect EOF", async () => { + const planFrame = encodeConnectFrame(toBinary(AgentServerMessageSchema, create(AgentServerMessageSchema, { + message: { + case: "interactionQuery", + value: create(InteractionQuerySchema, { + id: 7, + query: { + case: "createPlanRequestQuery", + value: create(CreatePlanRequestQuerySchema, { + args: create(CreatePlanArgsSchema, { + name: "Fix bridge", + overview: "Two steps.", + plan: "1. read\n2. patch", + }), + }), + }, + }), + }, + }))); + const connectEnd = encodeConnectFrame(new TextEncoder().encode("{}"), { + flags: CONNECT_FLAG_END_STREAM, + }); + + await withDiscoveryServer(stream => { + stream.respond({ ":status": 200, "content-type": "application/connect+proto" }); + stream.end(Buffer.from(new Uint8Array([...planFrame, ...connectEnd]))); + }, async baseUrl => { + const transport = createLiveCursorTransport({ + provider: { adapter: "cursor", baseUrl, apiKey: "test-token" }, + translatorBudget: createTestTranslatorBudget(), + firstFrameTimeoutMs: 2_000, + }); + const messages: Array<{ type: string; text?: string }> = []; + try { + for await (const message of transport.run({ + modelId: "composer-2", + conversationId: "cursor_plan_eof_test", + system: [], + messages: [{ role: "user", content: "hello" }], + })) { + messages.push(message); + } + } finally { + await transport.close?.(); + } + + expect(messages.some(message => message.type === "text" && message.text?.includes("Fix bridge"))).toBe(true); + expect(messages.at(-1)).toMatchObject({ type: "done" }); + }); + }); + test("zero-frame stream end surfaces as a transport error, not success", async () => { // Real h2c peer that accepts the request stream and immediately ends it with no // response frames — the shape the WP4 reviewer reproduced as a silent success. diff --git a/tests/provider-connection-test.test.ts b/tests/provider-connection-test.test.ts index ffb4746eb5..d68a0df126 100644 --- a/tests/provider-connection-test.test.ts +++ b/tests/provider-connection-test.test.ts @@ -70,7 +70,7 @@ describe("POST /api/providers/test (WP040 connectivity probe)", () => { const { body } = await probe(config, "cursor"); - expect(body).toMatchObject({ ok: true, message: "Connected. 2 models." }); + expect(body).toMatchObject({ ok: true, models: 2, message: "Connected. 2 models." }); expect(calls).toEqual([{ apiKey: "cursor-access-token", baseUrl: "https://api2.cursor.sh" }]); }); From 1824a014834d0747577e6c6850282e9a55940b88 Mon Sep 17 00:00:00 2001 From: SB Yoon <44089734+yansigit@users.noreply.github.com> Date: Mon, 17 Aug 2026 02:29:52 -0600 Subject: [PATCH 8/8] test(cursor): keep open-tool EOF as a truncation event CodeRabbit asked to throw on incomplete tools at Connect EOF. That would hide the existing fail-closed error event as a generic transport failure. --- src/adapters/cursor/live-transport.ts | 2 + tests/cursor-hardening.test.ts | 71 +++++++++++++++++++++++++++ 2 files changed, 73 insertions(+) diff --git a/src/adapters/cursor/live-transport.ts b/src/adapters/cursor/live-transport.ts index c5203106d7..da46e6f468 100644 --- a/src/adapters/cursor/live-transport.ts +++ b/src/adapters/cursor/live-transport.ts @@ -1033,6 +1033,8 @@ class LiveCursorTransport implements CursorTransport { 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(); diff --git a/tests/cursor-hardening.test.ts b/tests/cursor-hardening.test.ts index 4ab032ccf8..525cba635b 100644 --- a/tests/cursor-hardening.test.ts +++ b/tests/cursor-hardening.test.ts @@ -8,8 +8,12 @@ import { GetUsableModelsResponseSchema, InteractionQuerySchema, KvServerMessageSchema, + McpArgsSchema, + McpToolCallSchema, ModelDetailsSchema, TextDeltaUpdateSchema, + ToolCallSchema, + ToolCallStartedUpdateSchema, InteractionUpdateSchema, } from "../src/adapters/cursor/gen/agent_pb"; import { CONNECT_FLAG_END_STREAM, encodeConnectFrame } from "../src/adapters/cursor/framing"; @@ -491,6 +495,73 @@ describe("Cursor live transport unexpected EOF", () => { }); }); + test("open tool call plus clean Connect EOF emits a truncation error, not a thrown failure", async () => { + const startedFrame = encodeConnectFrame(toBinary(AgentServerMessageSchema, create(AgentServerMessageSchema, { + message: { + case: "interactionUpdate", + value: create(InteractionUpdateSchema, { + message: { + case: "toolCallStarted", + value: create(ToolCallStartedUpdateSchema, { + callId: "call_1", + modelCallId: "model_1", + toolCall: create(ToolCallSchema, { + tool: { + case: "mcpToolCall", + value: create(McpToolCallSchema, { + args: create(McpArgsSchema, { + name: "get_time", + toolName: "get_time", + toolCallId: "call_1", + providerIdentifier: "opencodex-responses", + }), + }), + }, + }), + }), + }, + }), + }, + }))); + const connectEnd = encodeConnectFrame(new TextEncoder().encode("{}"), { + flags: CONNECT_FLAG_END_STREAM, + }); + + await withDiscoveryServer(stream => { + stream.respond({ ":status": 200, "content-type": "application/connect+proto" }); + stream.end(Buffer.from(new Uint8Array([...startedFrame, ...connectEnd]))); + }, async baseUrl => { + const transport = createLiveCursorTransport({ + provider: { adapter: "cursor", baseUrl, apiKey: "test-token" }, + translatorBudget: createTestTranslatorBudget(), + firstFrameTimeoutMs: 2_000, + }); + const messages: Array<{ type: string; message?: string }> = []; + let failure: Error | undefined; + try { + for await (const message of transport.run({ + modelId: "composer-2", + conversationId: "cursor_open_tool_eof_test", + system: [], + messages: [{ role: "user", content: "hello" }], + tools: [{ name: "get_time", description: "t", parameters: { type: "object", properties: {} } }], + })) { + messages.push(message); + } + } catch (err) { + failure = err instanceof Error ? err : new Error(String(err)); + } finally { + await transport.close?.(); + } + + expect(failure).toBeUndefined(); + expect(messages.at(-1)).toMatchObject({ + type: "error", + message: expect.stringContaining("incomplete tool call"), + }); + }); + }); + test("zero-frame stream end surfaces as a transport error, not success", async () => { // Real h2c peer that accepts the request stream and immediately ends it with no // response frames — the shape the WP4 reviewer reproduced as a silent success.