695 lines · 20.7 KB · TypeScript
RawDownload
import * as path from "node:path";import * as os from "node:os";import { chmod, mkdtemp, readFile, writeFile } from "node:fs/promises";import { fileURLToPath } from "node:url"; import * as NodeServices from "@effect/platform-node/NodeServices";import { Effect } from "effect";import { describe, expect, it } from "vitest";import type * as EffectAcpSchema from "effect-acp/schema";import type { CursorSettings, ServerProviderModel } from "@t3tools/contracts";import { createModelCapabilities } from "@t3tools/shared/model"; import {  buildCursorProviderSnapshot,  buildCursorCapabilitiesFromConfigOptions,  buildCursorDiscoveredModelsFromConfigOptions,  discoverCursorModelCapabilitiesViaAcp,  discoverCursorModelsViaAcp,  getCursorFallbackModels,  getCursorParameterizedModelPickerUnsupportedMessage,  parseCursorAboutOutput,  parseCursorCliConfigChannel,  parseCursorVersionDate,  resolveCursorAcpBaseModelId,  resolveCursorAcpConfigUpdates,} from "./CursorProvider.ts"; const __dirname = path.dirname(fileURLToPath(import.meta.url));const mockAgentPath = path.join(__dirname, "../../../scripts/acp-mock-agent.ts"); function selectDescriptor(  id: string,  label: string,  options: ReadonlyArray<{ id: string; label: string; isDefault?: boolean }>,) {  return {    id,    label,    type: "select" as const,    options: [...options],    ...(options.find((option) => option.isDefault)?.id      ? { currentValue: options.find((option) => option.isDefault)?.id }      : {}),  };} function booleanDescriptor(id: string, label: string, currentValue?: boolean) {  return {    id,    label,    type: "boolean" as const,    ...(typeof currentValue === "boolean" ? { currentValue } : {}),  };} async function makeMockAgentWrapper(extraEnv?: Record<string, string>) {  const dir = await mkdtemp(path.join(os.tmpdir(), "cursor-provider-mock-"));  const wrapperPath = path.join(dir, "fake-agent.sh");  const envExports = Object.entries(extraEnv ?? {})    .map(([key, value]) => `export ${key}=${JSON.stringify(value)}`)    .join("\n");  const script = `#!/bin/sh${envExports}exec ${JSON.stringify("bun")} ${JSON.stringify(mockAgentPath)} "$@"`;  await writeFile(wrapperPath, script, "utf8");  await chmod(wrapperPath, 0o755);  return wrapperPath;} async function waitForFileContent(filePath: string, attempts = 40): Promise<string> {  for (let attempt = 0; attempt < attempts; attempt += 1) {    try {      const content = await readFile(filePath, "utf8");      if (content.trim().length > 0) {        return content;      }    } catch {}    await new Promise((resolve) => setTimeout(resolve, 50));  }  throw new Error(`Timed out waiting for file content at ${filePath}`);} const parameterizedGpt54ConfigOptions = [  {    type: "select",    currentValue: "gpt-5.4-medium-fast",    options: [{ name: "GPT-5.4", value: "gpt-5.4-medium-fast" }],    category: "model",    id: "model",    name: "Model",  },  {    type: "select",    currentValue: "medium",    options: [      { name: "None", value: "none" },      { name: "Low", value: "low" },      { name: "Medium", value: "medium" },      { name: "High", value: "high" },      { name: "Extra High", value: "extra-high" },    ],    category: "thought_level",    id: "reasoning",    name: "Reasoning",  },  {    type: "select",    currentValue: "272k",    options: [      { name: "272K", value: "272k" },      { name: "1M", value: "1m" },    ],    category: "model_config",    id: "context",    name: "Context",  },  {    type: "select",    currentValue: "false",    options: [      { name: "Off", value: "false" },      { name: "Fast", value: "true" },    ],    category: "model_config",    id: "fast",    name: "Fast",  },] satisfies ReadonlyArray<EffectAcpSchema.SessionConfigOption>; const parameterizedClaudeConfigOptions = [  {    type: "select",    currentValue: "claude-4.6-opus-high-thinking",    options: [{ name: "Opus 4.6", value: "claude-4.6-opus-high-thinking" }],    category: "model",    id: "model",    name: "Model",  },  {    type: "select",    currentValue: "high",    options: [      { name: "Low", value: "low" },      { name: "Medium", value: "medium" },      { name: "High", value: "high" },    ],    category: "thought_level",    id: "reasoning",    name: "Reasoning",  },  {    type: "boolean",    currentValue: true,    category: "model_config",    id: "thinking",    name: "Thinking",  },] satisfies ReadonlyArray<EffectAcpSchema.SessionConfigOption>; const parameterizedClaudeModelOptionConfigOptions = [  {    type: "select",    currentValue: "claude-opus-4-6",    options: [{ name: "Opus 4.6", value: "claude-opus-4-6" }],    category: "model",    id: "model",    name: "Model",  },  {    type: "select",    currentValue: "high",    options: [      { name: "Low", value: "low" },      { name: "Medium", value: "medium" },      { name: "High", value: "high" },    ],    category: "thought_level",    id: "reasoning",    name: "Reasoning",  },  {    type: "select",    currentValue: "max",    options: [      { name: "Low", value: "low" },      { name: "Medium", value: "medium" },      { name: "High", value: "high" },      { name: "Max", value: "max" },    ],    category: "model_option",    id: "effort",    name: "Effort",  },  {    type: "select",    currentValue: "true",    options: [      { name: "Off", value: "false" },      { name: "Fast", value: "true" },    ],    category: "model_config",    id: "fast",    name: "Fast",  },  {    type: "select",    currentValue: "true",    options: [      { name: "Off", value: "false" },      { name: ":icon-brain:", value: "true" },    ],    category: "model_config",    id: "thinking",    name: "Thinking",  },] satisfies ReadonlyArray<EffectAcpSchema.SessionConfigOption>; const sessionNewCursorConfigOptions = [  {    type: "select",    currentValue: "agent",    options: [      { name: "Agent", value: "agent", description: "Full agent capabilities with tool access" },    ],    category: "mode",    id: "mode",    name: "Mode",    description: "Controls how the agent executes tasks",  },  {    type: "select",    currentValue: "composer-2",    options: [      { name: "Auto", value: "default" },      { name: "Composer 2", value: "composer-2" },      { name: "GPT-5.4", value: "gpt-5.4" },      { name: "Sonnet 4.6", value: "claude-sonnet-4-6" },      { name: "Opus 4.6", value: "claude-opus-4-6" },      { name: "Codex 5.3 Spark", value: "gpt-5.3-codex-spark" },    ],    category: "model",    id: "model",    name: "Model",    description: "Controls which model is used for responses",  },  {    type: "select",    currentValue: "true",    options: [      { name: "Off", value: "false" },      { name: "Fast", value: "true" },    ],    category: "model_config",    id: "fast",    name: "Fast",    description: "Faster speeds.",  },] satisfies ReadonlyArray<EffectAcpSchema.SessionConfigOption>; const baseCursorSettings: CursorSettings = {  enabled: true,  binaryPath: "agent",  apiEndpoint: "",  customModels: [],}; const emptyCapabilities = createModelCapabilities({ optionDescriptors: [] }); describe("getCursorFallbackModels", () => {  it("does not publish any built-in cursor models before ACP discovery", () => {    expect(      getCursorFallbackModels({        customModels: ["internal/cursor-model"],      }).map((model) => model.slug),    ).toEqual(["internal/cursor-model"]);  });}); describe("buildCursorProviderSnapshot", () => {  it("downgrades ready status to warning when ACP model discovery times out", () => {    expect(      buildCursorProviderSnapshot({        checkedAt: "2026-01-01T00:00:00.000Z",        cursorSettings: baseCursorSettings,        parsed: {          version: "2026.04.09-f2b0fcd",          status: "ready",          auth: { status: "authenticated", type: "Team", label: "Cursor Team Subscription" },        },        discoveryWarning: "Cursor ACP model discovery timed out after 15000ms.",      }),    ).toMatchObject({      status: "warning",      message: "Cursor ACP model discovery timed out after 15000ms.",      models: [],    });  });   it("preserves provider error state while appending discovery warnings", () => {    expect(      buildCursorProviderSnapshot({        checkedAt: "2026-01-01T00:00:00.000Z",        cursorSettings: {          ...baseCursorSettings,          customModels: ["claude-sonnet-4-6"],        },        parsed: {          version: "2026.04.09-f2b0fcd",          status: "error",          auth: { status: "unauthenticated" },          message: "Cursor Agent is not authenticated. Run `agent login` and try again.",        },        discoveryWarning: "Cursor ACP model discovery failed. Check server logs for details.",      }),    ).toMatchObject({      status: "error",      message:        "Cursor Agent is not authenticated. Run `agent login` and try again. Cursor ACP model discovery failed. Check server logs for details.",      models: [        {          slug: "claude-sonnet-4-6",          isCustom: true,        },      ],    });  });}); describe("buildCursorCapabilitiesFromConfigOptions", () => {  it("derives model capabilities from parameterized Cursor ACP config options", () => {    expect(buildCursorCapabilitiesFromConfigOptions(parameterizedGpt54ConfigOptions)).toEqual(      createModelCapabilities({        optionDescriptors: [          selectDescriptor("reasoning", "Reasoning", [            { id: "low", label: "Low" },            { id: "medium", label: "Medium", isDefault: true },            { id: "high", label: "High" },            { id: "xhigh", label: "Extra High" },          ]),          selectDescriptor("contextWindow", "Context", [            { id: "272k", label: "272K", isDefault: true },            { id: "1m", label: "1M" },          ]),          booleanDescriptor("fastMode", "Fast", false),        ],      }),    );  });   it("detects boolean thinking toggles from model_config options", () => {    expect(buildCursorCapabilitiesFromConfigOptions(parameterizedClaudeConfigOptions)).toEqual(      createModelCapabilities({        optionDescriptors: [          selectDescriptor("reasoning", "Reasoning", [            { id: "low", label: "Low" },            { id: "medium", label: "Medium" },            { id: "high", label: "High", isDefault: true },          ]),          booleanDescriptor("thinking", "Thinking", true),        ],      }),    );  });   it("prefers the newer model_option effort control over legacy thought_level", () => {    expect(      buildCursorCapabilitiesFromConfigOptions(parameterizedClaudeModelOptionConfigOptions),    ).toEqual(      createModelCapabilities({        optionDescriptors: [          selectDescriptor("reasoning", "Effort", [            { id: "low", label: "Low" },            { id: "medium", label: "Medium" },            { id: "high", label: "High" },            { id: "max", label: "Max", isDefault: true },          ]),          booleanDescriptor("fastMode", "Fast", true),          booleanDescriptor("thinking", "Thinking", true),        ],      }),    );  });}); describe("buildCursorDiscoveredModelsFromConfigOptions", () => {  it("publishes ACP model choices immediately from session/new config options", () => {    expect(buildCursorDiscoveredModelsFromConfigOptions(sessionNewCursorConfigOptions)).toEqual([      {        slug: "default",        name: "Auto",        isCustom: false,        capabilities: emptyCapabilities,      },      {        slug: "composer-2",        name: "Composer 2",        isCustom: false,        capabilities: createModelCapabilities({          optionDescriptors: [booleanDescriptor("fastMode", "Fast", true)],        }),      },      {        slug: "gpt-5.4",        name: "GPT-5.4",        isCustom: false,        capabilities: emptyCapabilities,      },      {        slug: "claude-sonnet-4-6",        name: "Sonnet 4.6",        isCustom: false,        capabilities: emptyCapabilities,      },      {        slug: "claude-opus-4-6",        name: "Opus 4.6",        isCustom: false,        capabilities: emptyCapabilities,      },      {        slug: "gpt-5.3-codex-spark",        name: "Codex 5.3 Spark",        isCustom: false,        capabilities: emptyCapabilities,      },    ]);  });}); describe("discoverCursorModelsViaAcp", () => {  it("keeps the ACP probe runtime alive long enough to discover models", async () => {    const wrapperPath = await makeMockAgentWrapper();     const models = await Effect.runPromise(      discoverCursorModelsViaAcp({        enabled: true,        binaryPath: wrapperPath,        apiEndpoint: "",        customModels: [],      }).pipe(Effect.provide(NodeServices.layer), Effect.scoped),    );     expect(models.map((model) => model.slug)).toEqual([      "default",      "composer-2",      "gpt-5.4",      "claude-opus-4-6",    ]);  });   it("closes the ACP probe runtime after discovery completes", async () => {    const tempDir = await mkdtemp(path.join(os.tmpdir(), "cursor-provider-exit-log-"));    const exitLogPath = path.join(tempDir, "exit.log");    const wrapperPath = await makeMockAgentWrapper({      T3_ACP_EXIT_LOG_PATH: exitLogPath,    });     await Effect.runPromise(      discoverCursorModelsViaAcp({        enabled: true,        binaryPath: wrapperPath,        apiEndpoint: "",        customModels: [],      }).pipe(Effect.provide(NodeServices.layer)),    );     const exitLog = await waitForFileContent(exitLogPath);    expect(exitLog).toContain("SIGTERM");  });}); describe("discoverCursorModelCapabilitiesViaAcp", () => {  it("discovers model capabilities through Cursor's list_available_models extension method", async () => {    const tempDir = await mkdtemp(path.join(os.tmpdir(), "cursor-capabilities-exit-log-"));    const exitLogPath = path.join(tempDir, "exit.log");    const wrapperPath = await makeMockAgentWrapper({      T3_ACP_EXIT_LOG_PATH: exitLogPath,    });    const existingModels: ReadonlyArray<ServerProviderModel> = [      { slug: "default", name: "Auto", isCustom: false, capabilities: emptyCapabilities },      { slug: "composer-2", name: "Composer 2", isCustom: false, capabilities: emptyCapabilities },      { slug: "gpt-5.4", name: "GPT-5.4", isCustom: false, capabilities: emptyCapabilities },      {        slug: "claude-opus-4-6",        name: "Opus 4.6",        isCustom: false,        capabilities: emptyCapabilities,      },    ];     const models = await Effect.runPromise(      discoverCursorModelCapabilitiesViaAcp(        {          enabled: true,          binaryPath: wrapperPath,          apiEndpoint: "",          customModels: [],        },        existingModels,

Lines 1–500 of 695.