diff --git a/package.json b/package.json index fc5a898..331716d 100644 --- a/package.json +++ b/package.json @@ -12,7 +12,7 @@ "test:unit": "bun test --isolate tests/unit", "test:integration": "bun test tests/integration", "test:perf": "bun test tests/unit/perf.test.ts tests/unit/utils/logger.test.ts tests/unit/proxy/prompt-builder.test.ts tests/unit/proxy/session-resume.test.ts tests/unit/proxy/incremental-prompt.test.ts tests/unit/proxy/plugin-resume.test.ts tests/unit/cursor-agent-child.test.ts tests/unit/cursor-agent-runner.test.ts tests/unit/streaming/line-buffer.test.ts tests/unit/streaming/parser.test.ts tests/unit/streaming/delta-tracker.test.ts tests/unit/streaming/openai-sse.test.ts tests/integration/sdk-demux-roundtrip.test.ts", - "test:ci:unit": "bun test --isolate tests/tools/defaults.test.ts tests/tools/executor-chain.test.ts tests/tools/sdk-executor.test.ts tests/tools/mcp-executor.test.ts tests/tools/skills.test.ts tests/tools/registry.test.ts tests/unit/cli/opencode-cursor.test.ts tests/unit/cli/cursor-bridge-install.test.ts tests/unit/cli/model-discovery.test.ts tests/unit/cursor-agent-child.test.ts tests/unit/cursor-agent-pool.test.ts tests/unit/cursor-agent-runner.test.ts tests/unit/errors.test.ts tests/unit/binary-strict.test.ts tests/unit/cursor-agent-fallback.test.ts tests/unit/models/discovery.test.ts tests/unit/proxy/bridge-json.test.ts tests/unit/proxy/prompt-builder.test.ts tests/unit/proxy/tool-loop.test.ts tests/unit/proxy/session-resume.test.ts tests/unit/proxy/incremental-prompt.test.ts tests/unit/proxy/plugin-resume.test.ts tests/unit/provider-backend.test.ts tests/unit/provider-boundary.test.ts tests/unit/provider-runtime-interception.test.ts tests/unit/provider-tool-schema-compat.test.ts tests/unit/provider-tool-loop-guard.test.ts tests/unit/mcp/tool-bridge.test.ts tests/unit/sdk-child.test.ts tests/unit/sdk-runner.test.ts tests/unit/plugin.test.ts tests/unit/plugin-system-message.test.ts tests/unit/plugin-tools-hook.test.ts tests/unit/plugin-tool-resolution.test.ts tests/unit/plugin-config.test.ts tests/unit/plugin-stream-extraction.test.ts tests/unit/auth.test.ts tests/unit/streaming/line-buffer.test.ts tests/unit/streaming/parser.test.ts tests/unit/streaming/types.test.ts tests/unit/streaming/delta-tracker.test.ts tests/unit/streaming/openai-sse.test.ts tests/unit/streaming/ai-sdk-parts.test.ts tests/competitive/edge.test.ts", + "test:ci:unit": "bun test --isolate tests/tools/defaults.test.ts tests/tools/executor-chain.test.ts tests/tools/sdk-executor.test.ts tests/tools/mcp-executor.test.ts tests/tools/skills.test.ts tests/tools/registry.test.ts tests/unit/cli/opencode-cursor.test.ts tests/unit/cli/cursor-bridge-install.test.ts tests/unit/cli/model-discovery.test.ts tests/unit/cursor-agent-child.test.ts tests/unit/cursor-agent-pool.test.ts tests/unit/cursor-agent-runner.test.ts tests/unit/errors.test.ts tests/unit/binary-strict.test.ts tests/unit/cursor-agent-fallback.test.ts tests/unit/models/discovery.test.ts tests/unit/proxy/bridge-json.test.ts tests/unit/proxy/prompt-builder.test.ts tests/unit/proxy/tool-loop.test.ts tests/unit/proxy/session-resume.test.ts tests/unit/proxy/incremental-prompt.test.ts tests/unit/proxy/plugin-resume.test.ts tests/unit/provider-backend.test.ts tests/unit/provider-boundary.test.ts tests/unit/provider-runtime-interception.test.ts tests/unit/provider-tool-schema-compat.test.ts tests/unit/provider-tool-loop-guard.test.ts tests/unit/mcp/tool-bridge.test.ts tests/unit/sdk-child.test.ts tests/unit/sdk-runner.test.ts tests/unit/plugin.test.ts tests/unit/plugin-v2.test.ts tests/unit/plugin-system-message.test.ts tests/unit/plugin-tools-hook.test.ts tests/unit/plugin-tool-resolution.test.ts tests/unit/plugin-config.test.ts tests/unit/plugin-stream-extraction.test.ts tests/unit/auth.test.ts tests/unit/streaming/line-buffer.test.ts tests/unit/streaming/parser.test.ts tests/unit/streaming/types.test.ts tests/unit/streaming/delta-tracker.test.ts tests/unit/streaming/openai-sse.test.ts tests/unit/streaming/ai-sdk-parts.test.ts tests/competitive/edge.test.ts", "test:ci:integration": "bun test tests/integration/comprehensive.test.ts tests/integration/tools-router.integration.test.ts tests/integration/stream-router.integration.test.ts tests/integration/opencode-loop.integration.test.ts", "verify:issue-92": "bash scripts/verify-issue-92.sh", "check:pricing": "bun run scripts/check-cursor-pricing-coverage.ts", diff --git a/src/plugin-v2.ts b/src/plugin-v2.ts index 14841b4..0ca898c 100644 --- a/src/plugin-v2.ts +++ b/src/plugin-v2.ts @@ -17,7 +17,6 @@ import { buildAvailableToolsSystemMessage, buildToolHookEntries, ensureCursorProxyServer, - resolveWorkspaceDirectory, ensurePluginDirectory, setStoredApiKey, } from "./plugin.js"; @@ -36,18 +35,59 @@ import { SkillLoader } from "./tools/skills/loader.js"; import { SkillResolver } from "./tools/skills/resolver.js"; import { LocalExecutor } from "./tools/executors/local.js"; import { executeWithChain } from "./tools/core/executor.js"; -import { buildLocalFallbackTools, shouldRegisterNativeToolHook } from "./plugin.js"; -import { createOpencodeClient } from "@opencode-ai/sdk"; -import { TOOL_HOOK_EXCLUSIONS, TOOL_LOOP_MODE } from "./plugin.js"; +import { buildLocalFallbackTools, TOOL_LOOP_MODE } from "./plugin.js"; const log = createLogger("plugin-v2"); +type V2Registration = { dispose: () => Promise | void }; +type V2ToolDefinition = { + name: string; + description: string; + input: Record; + execute: (args: any, ctx: any) => Promise; + options: { codemode: true }; +}; +type V2Context = { + catalog: { + transform: (callback: (draft: { + provider: { update: (id: string, update: (provider: { + name: string; + settings?: Record; + }) => void) => void }; + }) => void) => Promise; + }; + integration: { + transform: (callback: (draft: { + method: { update: (input: unknown) => void }; + }) => void) => Promise; + connection: { + active: (integrationID: string) => Promise | undefined>; + resolve: (connection: Record) => Promise<{ + type: string; + key?: string; + } | undefined>; + }; + }; + tool: { + transform: (callback: (draft: { + add: (tool: V2ToolDefinition) => void; + }) => void) => Promise; + }; + session: { + hook: (name: "context", callback: (event: { + model?: { providerID?: string }; + system: Array<{ type: "text"; text: string }>; + tools: Record }>; + }) => Promise) => Promise; + }; +}; + /** Convert a V1-style tool entry (with zod args) into a V2 tool definition. */ function v2ToolFromV1( name: string, entry: any, jsonSchema?: Record, -): { description: string; input: Record; execute: (args: any, ctx: any) => Promise } { +): V2ToolDefinition { const description = typeof entry?.description === "string" ? entry.description : name; // Prefer the original JSON schema when available; fall back to an empty object. const input = @@ -57,8 +97,10 @@ function v2ToolFromV1( const v1Execute = typeof entry?.execute === "function" ? entry.execute : async () => ({ content: "" }); return { + name, description, input, + options: { codemode: true }, async execute(args: any, executeCtx: any) { const result = await v1Execute(args, executeCtx); // V1 tools return a string; V2 expects { content } or { output, content }. @@ -71,7 +113,7 @@ function v2ToolFromV1( } export function createV2Setup() { - return async (ctx: any) => { + return async (ctx: V2Context) => { const state = shouldEnableCursorPlugin(); if (!state.enabled) { log.info("Plugin disabled in OpenCode config; skipping initialization", { @@ -81,12 +123,12 @@ export function createV2Setup() { return; } - const workspaceDirectory = resolveWorkspaceDirectory(ctx.worktree, ctx.directory); + const registrations: V2Registration[] = []; + + // ponytail: V2 exposes no workspace path; use cwd until its Context adds one. + const workspaceDirectory = process.cwd(); log.debug("V2 plugin initializing", { - directory: ctx.directory, - worktree: ctx.worktree, workspaceDirectory, - cwd: process.cwd(), }); await ensurePluginDirectory(); @@ -127,9 +169,6 @@ export function createV2Setup() { // Tools (skills) discovery/execution wiring (same as V1). const toolsEnabled = process.env.CURSOR_ACP_ENABLE_OPENCODE_TOOLS !== "false"; const legacyProxyToolPathsEnabled = toolsEnabled && TOOL_LOOP_MODE === "proxy-exec"; - const serverClient = legacyProxyToolPathsEnabled - ? createOpencodeClient({ baseUrl: ctx.serverUrl?.toString(), directory: workspaceDirectory }) - : null; const localRegistry = new CoreRegistry(); registerDefaultTools(localRegistry); @@ -147,45 +186,29 @@ export function createV2Setup() { }) : null; - let lastToolNames: string[] = []; - let lastToolMap: Array<{ id: string; name: string }> = []; - - const refreshTools = async () => { - toolsByName.clear(); - const toolEntries: any[] = []; - const localTools = buildLocalFallbackTools(localRegistry, TOOL_LOOP_MODE); - for (const asTool of localTools) { - toolsByName.set(asTool.name, asTool); - toolEntries.push({ type: "function", function: { name: asTool.name, parameters: {} } }); - } - const skills = skillLoader.load([...localTools]); - skillResolver = new SkillResolver(skills); - lastToolNames = toolEntries.map((e) => e.function.name); - lastToolMap = localTools.map((t: any) => ({ id: t.id, name: t.name })); - return toolEntries; - }; + const localTools = buildLocalFallbackTools(localRegistry, TOOL_LOOP_MODE); + for (const tool of localTools) toolsByName.set(tool.name, tool); + skillResolver = new SkillResolver(skillLoader.load(localTools)); + const lastToolNames = localTools.map((tool) => tool.name); + const lastToolMap = localTools.map((tool) => ({ id: tool.id, name: tool.name })); const proxyBaseURL = await ensureCursorProxyServer(workspaceDirectory, router ?? undefined); log.debug("Proxy server started", { baseURL: proxyBaseURL }); // Register the cursor-acp provider + auth via catalog/integration transforms. - await ctx.catalog.transform((catalog: any) => { - catalog.provider.update(CURSOR_PROVIDER_ID, (p: any) => { + registrations.push(await ctx.catalog.transform((catalog) => { + catalog.provider.update(CURSOR_PROVIDER_ID, (p) => { p.name = "Cursor"; - p.api = { - type: "aisdk", - package: "aisdk:@ai-sdk/openai-compatible", - settings: { baseURL: proxyBaseURL }, - }; + p.settings = { ...p.settings, baseURL: proxyBaseURL }; }); - }); + })); - await ctx.integration.transform((integrations: any) => { + registrations.push(await ctx.integration.transform((integrations) => { integrations.method.update({ integrationID: CURSOR_PROVIDER_ID, method: { type: "key", label: "Cursor API Key (cursor.com/settings)" }, }); - }); + })); // Register local + MCP tools as V2 tools (best-effort). try { @@ -198,59 +221,45 @@ export function createV2Setup() { schemaByName.set(t.name, t.parameters); } - await ctx.tool.transform((tools: any) => { + registrations.push(await ctx.tool.transform((tools) => { for (const [name, entry] of Object.entries(allEntries)) { const def = v2ToolFromV1(name, entry, schemaByName.get(name)); - tools.add(name, def, { codemode: true }); + tools.add(def); } - }); + })); } catch (err) { log.debug("Tool registration failed", { error: String(err) }); } - // Chat-params equivalent: force baseURL + inject MCP tool defs, and append - // the available-tools system message on every turn. - await ctx.session.hook("context", async (event: any) => { + // Resolve credentials and append the available-tools message on Cursor turns. + registrations.push(await ctx.session.hook("context", async (event) => { + const modelRef = event.model; + const isCursor = modelRef?.providerID === CURSOR_PROVIDER_ID; + if (!isCursor) return; + // V1 filled this from auth.loader. In V2 resolve the active integration // connection before every Cursor turn so the local proxy gets the key. try { const connection = await ctx.integration.connection.active(CURSOR_PROVIDER_ID); const credential = connection && await ctx.integration.connection.resolve(connection); - if (credential?.type === "key") setStoredApiKey(credential.key); + setStoredApiKey(credential?.type === "key" ? credential.key : undefined); } catch (err) { + setStoredApiKey(undefined); log.debug("Could not resolve Cursor API key", { error: String(err) }); } - const modelRef = event.model; - const isCursor = modelRef?.providerID === CURSOR_PROVIDER_ID; - if (!isCursor) return; - - if (toolsEnabled && TOOL_LOOP_MODE === "opencode") { - const existingTools = event.tools; - if (existingTools == null) { - const refreshed = await refreshTools(); - event.tools = refreshed; - } - } - - if (mcpToolDefs.length > 0) { - // Surface MCP tools through the tools record so the model can call them. - const current = event.tools && typeof event.tools === "object" ? event.tools : {}; - for (const def of mcpToolDefs) { - const fname = def?.function?.name; - if (fname && !(fname in current)) { - current[fname] = def; - } - } - event.tools = current; - } - const systemMessage = buildAvailableToolsSystemMessage( lastToolNames, lastToolMap, mcpToolDefs, mcpToolSummaries, ); if (systemMessage) { event.system.push({ type: "text", text: systemMessage }); } - }); + })); + + return async () => { + await Promise.allSettled(registrations.reverse().map((item) => item.dispose())); + await mcpManager.disconnectAll(); + setStoredApiKey(undefined); + }; }; } diff --git a/src/plugin.ts b/src/plugin.ts index c99cdc5..ea5cf93 100644 --- a/src/plugin.ts +++ b/src/plugin.ts @@ -202,6 +202,10 @@ let storedApiKey: string | undefined; export function setStoredApiKey(apiKey: string | undefined): void { storedApiKey = apiKey; } + +export function getStoredApiKey(): string | undefined { + return storedApiKey; +} let cursorAgentAvailabilityCache: boolean | undefined; function getGlobalKey(): string { diff --git a/tests/unit/plugin-v2.test.ts b/tests/unit/plugin-v2.test.ts new file mode 100644 index 0000000..e004fd9 --- /dev/null +++ b/tests/unit/plugin-v2.test.ts @@ -0,0 +1,191 @@ +import { afterAll, describe, expect, test } from "bun:test"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +const configHome = mkdtempSync(join(tmpdir(), "open-cursor-v2-")); +const previousEnv = { + XDG_CONFIG_HOME: process.env.XDG_CONFIG_HOME, + OPENCODE_CONFIG: process.env.OPENCODE_CONFIG, + CURSOR_ACP_MCP_BRIDGE: process.env.CURSOR_ACP_MCP_BRIDGE, + CURSOR_ACP_MODEL_AUTO_REFRESH: process.env.CURSOR_ACP_MODEL_AUTO_REFRESH, + CURSOR_ACP_REUSE_EXISTING_PROXY: process.env.CURSOR_ACP_REUSE_EXISTING_PROXY, +}; + +process.env.XDG_CONFIG_HOME = configHome; +process.env.OPENCODE_CONFIG = join(configHome, "missing.json"); +process.env.CURSOR_ACP_MCP_BRIDGE = "false"; +process.env.CURSOR_ACP_MODEL_AUTO_REFRESH = "false"; +process.env.CURSOR_ACP_REUSE_EXISTING_PROXY = "false"; + +const pluginModule = await import("../../src/plugin.js") as typeof import("../../src/plugin.js") & { + getStoredApiKey?: () => string | undefined; +}; +const { createV2Setup } = await import("../../src/plugin-v2.js"); + +type Registration = { dispose: () => Promise }; + +function registration(onDispose: () => void = () => {}): Registration { + return { dispose: async () => onDispose() }; +} + +function createContext() { + const disposed: string[] = []; + const toolAddCalls: any[][] = []; + let sessionContextHook: ((event: any) => Promise) | undefined; + let activeConnectionCalls = 0; + let credential: any = { type: "key", key: "cursor-key" }; + const provider: Record = { + id: "cursor-acp", + name: "Old Cursor", + package: "@ai-sdk/openai-compatible", + settings: { baseURL: "http://127.0.0.1:9/v1", keep: true }, + }; + + const context = { + catalog: { + transform: async (callback: (draft: any) => void) => { + callback({ + provider: { + update: (id: string, update: (value: any) => void) => { + expect(id).toBe("cursor-acp"); + update(provider); + }, + }, + }); + return registration(() => disposed.push("catalog")); + }, + }, + integration: { + transform: async (callback: (draft: any) => void) => { + callback({ method: { update: () => {} } }); + return registration(() => disposed.push("integration")); + }, + connection: { + active: async () => { + activeConnectionCalls += 1; + return { id: "cursor-connection" }; + }, + resolve: async () => credential, + }, + }, + tool: { + transform: async (callback: (draft: any) => void) => { + callback({ add: (...args: any[]) => toolAddCalls.push(args) }); + return registration(() => disposed.push("tool")); + }, + }, + session: { + hook: async (name: string, callback: (event: any) => Promise) => { + expect(name).toBe("context"); + sessionContextHook = callback; + return registration(() => disposed.push("session")); + }, + }, + }; + + return { + context, + disposed, + provider, + toolAddCalls, + activeConnectionCalls: () => activeConnectionCalls, + setCredential: (value: any) => { credential = value; }, + sessionContextHook: () => sessionContextHook, + }; +} + +afterAll(() => { + for (const [name, value] of Object.entries(previousEnv)) { + if (value === undefined) delete process.env[name]; + else process.env[name] = value; + } + rmSync(configHome, { recursive: true, force: true }); +}); + +describe("opencode V2 adapter", () => { + test("writes the proxy URL through provider settings", async () => { + const { context, provider } = createContext(); + + await createV2Setup()(context); + + expect(provider.package).toBe("@ai-sdk/openai-compatible"); + expect(provider.settings.keep).toBe(true); + expect(provider.settings.baseURL).toMatch(/^http:\/\/127\.0\.0\.1:\d+\/v1$/); + expect(provider.settings.baseURL).not.toBe("http://127.0.0.1:9/v1"); + expect(provider.api).toBeUndefined(); + }); + + test("registers complete tools with one V2 add argument", async () => { + const { context, toolAddCalls } = createContext(); + + await createV2Setup()(context); + + expect(toolAddCalls.length).toBeGreaterThan(0); + for (const args of toolAddCalls) { + expect(args).toHaveLength(1); + expect(args[0]).toEqual(expect.objectContaining({ + name: expect.any(String), + description: expect.any(String), + input: expect.any(Object), + execute: expect.any(Function), + options: { codemode: true }, + })); + } + }); + + test("does not resolve Cursor credentials for other providers", async () => { + const fixture = createContext(); + await createV2Setup()(fixture.context); + + await fixture.sessionContextHook()!({ + model: { providerID: "ollama" }, + system: [], + tools: {}, + }); + + expect(fixture.activeConnectionCalls()).toBe(0); + }); + + test("clears the stored key when the active connection has no credential", async () => { + const fixture = createContext(); + await createV2Setup()(fixture.context); + const event = { + model: { providerID: "cursor-acp" }, + system: [], + tools: {}, + }; + + expect(pluginModule.getStoredApiKey).toBeTypeOf("function"); + await fixture.sessionContextHook()!(event); + expect(pluginModule.getStoredApiKey!()).toBe("cursor-key"); + + fixture.setCredential(undefined); + await fixture.sessionContextHook()!(event); + expect(pluginModule.getStoredApiKey!()).toBeUndefined(); + }); + + test("does not replace the host session tool record", async () => { + const fixture = createContext(); + await createV2Setup()(fixture.context); + const event: any = { + model: { providerID: "cursor-acp" }, + system: [], + tools: undefined, + }; + + await fixture.sessionContextHook()!(event); + + expect(event.tools).toBeUndefined(); + }); + + test("returns cleanup that disposes every V2 registration", async () => { + const fixture = createContext(); + + const cleanup = await createV2Setup()(fixture.context); + + expect(cleanup).toBeTypeOf("function"); + await cleanup!(); + expect(fixture.disposed.sort()).toEqual(["catalog", "integration", "session", "tool"]); + }); +});