783 lines · 23.4 KB · TypeScript
RawDownload
import * as NodeOS from "node:os"; import * as NodeServices from "@effect/platform-node/NodeServices";import * as Effect from "effect/Effect";import * as FileSystem from "effect/FileSystem";import * as Path from "effect/Path";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,  checkCursorProviderStatus,  discoverCursorModelCapabilitiesViaAcp,  discoverCursorModelsViaAcp,  getCursorFallbackModels,  getCursorParameterizedModelPickerUnsupportedMessage,  parseCursorAboutOutput,  parseCursorCliConfigChannel,  parseCursorVersionDate,  resolveCursorAcpBaseModelId,  resolveCursorAcpConfigUpdates,} from "./CursorProvider.ts"; const runNode = <A, E>(  effect: Effect.Effect<A, E, FileSystem.FileSystem | Path.Path>,): Promise<A> => Effect.runPromise(effect.pipe(Effect.provide(NodeServices.layer))); const resolveMockAgentPath = Effect.fn("resolveMockAgentPath")(function* () {  const path = yield* Path.Path;  return yield* path.fromFileUrl(new URL("../../../scripts/acp-mock-agent.ts", import.meta.url));}); 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 } : {}),  };} const makeMockAgentWrapper = Effect.fn("makeMockAgentWrapper")(function* (  extraEnv?: Record<string, string>,) {  const fileSystem = yield* FileSystem.FileSystem;  const path = yield* Path.Path;  const mockAgentPath = yield* resolveMockAgentPath();  const dir = yield* fileSystem.makeTempDirectory({    directory: NodeOS.tmpdir(),    prefix: "cursor-provider-mock-",  });  const wrapperPath = path.join(dir, "fake-agent.sh");  // @effect-diagnostics-next-line preferSchemaOverJson:off  const bunCommand = JSON.stringify("bun");  // @effect-diagnostics-next-line preferSchemaOverJson:off  const mockAgentPathJson = JSON.stringify(mockAgentPath);  const envExports = Object.entries(extraEnv ?? {})    .map(([key, value]) => `export ${key}=${JSON.stringify(value)}`)    .join("\n");  const script = `#!/bin/sh${envExports}exec ${bunCommand} ${mockAgentPathJson} "$@"`;  yield* fileSystem.writeFileString(wrapperPath, script);  yield* fileSystem.chmod(wrapperPath, 0o755);  return wrapperPath;}); const makeMockAgentWithAboutWrapper = Effect.fn("makeMockAgentWithAboutWrapper")(function* () {  const fileSystem = yield* FileSystem.FileSystem;  const path = yield* Path.Path;  const mockAgentPath = yield* resolveMockAgentPath();  const dir = yield* fileSystem.makeTempDirectory({    directory: NodeOS.tmpdir(),    prefix: "cursor-provider-about-mock-",  });  const wrapperPath = path.join(dir, "fake-agent.sh");  // @effect-diagnostics-next-line preferSchemaOverJson:off  const bunCommand = JSON.stringify("bun");  // @effect-diagnostics-next-line preferSchemaOverJson:off  const mockAgentPathJson = JSON.stringify(mockAgentPath);  const script = `#!/bin/shif [ "$1" = "about" ]; then  printf 'CLI Version         2026.04.09-f2b0fcd\\n'  printf 'User Email          cursor@example.com\\n'  exit 0fiexec ${bunCommand} ${mockAgentPathJson} "$@"`;  yield* fileSystem.writeFileString(wrapperPath, script);  yield* fileSystem.chmod(wrapperPath, 0o755);  return wrapperPath;}); const waitForFileContent = Effect.fn("waitForFileContent")(function* (  filePath: string,  attempts = 40,) {  const fileSystem = yield* FileSystem.FileSystem;  for (let attempt = 0; attempt < attempts; attempt += 1) {    const content = yield* fileSystem      .readFileString(filePath)      .pipe(Effect.catch(() => Effect.void));    if (content !== undefined) {      if (content.trim().length > 0) {        return content;      }    }    yield* Effect.sleep("50 millis");  }  return yield* Effect.die(`Timed out waiting for file content at ${filePath}`);}); const makeProviderStatusEnvFixture = Effect.fn("makeProviderStatusEnvFixture")(function* () {  const fileSystem = yield* FileSystem.FileSystem;  const path = yield* Path.Path;  const tempDir = yield* fileSystem.makeTempDirectory({    directory: NodeOS.tmpdir(),    prefix: "cursor-provider-status-env-",  });  return {    requestLogPath: path.join(tempDir, "requests.ndjson"),    wrapperPath: yield* makeMockAgentWithAboutWrapper(),  };}); const makeExitLogFixture = Effect.fn("makeExitLogFixture")(function* (prefix: string) {  const fileSystem = yield* FileSystem.FileSystem;  const path = yield* Path.Path;  const tempDir = yield* fileSystem.makeTempDirectory({    directory: NodeOS.tmpdir(),    prefix,  });  const exitLogPath = path.join(tempDir, "exit.log");  return {    exitLogPath,    wrapperPath: yield* makeMockAgentWrapper({      T3_ACP_EXIT_LOG_PATH: exitLogPath,    }),  };}); 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,      },      {

Lines 1–500 of 783.