diff --git a/CHANGELOG.md b/CHANGELOG.md index 1d98dcb..038301e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,10 @@ All notable changes to this project will be documented in this file. ## [Unreleased] +- **Improved `cursor_delegate` visibility.** Local Cursor delegation now runs in a + parent-linked opencode child session, so live Cursor tool activity is visible and + navigable in the subagent UI. Child sessions remain available after completion. + ## [0.4.6] — 2026-07-08 - **Fixed: newly released Cursor models didn't appear locally without a manual diff --git a/README.md b/README.md index 0d60bba..0bd2760 100644 --- a/README.md +++ b/README.md @@ -106,8 +106,9 @@ The key is validated on first use (model discovery / first call), not at login t The plugin also registers two **delegation tools**: -- `cursor_delegate` — hand a discrete subtask to a local Cursor agent as a permission-gated tool - call (your primary model stays in control). +- `cursor_delegate` — hand a discrete subtask to a local Cursor agent in a permission-gated, + parent-linked child session. Cursor's live tool activity appears in opencode's subagent UI while + your primary model stays in control; the child session remains available after completion. - `cursor_cloud_agent` — launch a Cursor cloud agent on a remote repo that can run for minutes and optionally open a PR. @@ -250,8 +251,9 @@ are gated by opencode's `permission` config: ### `cursor_delegate` (local) -Runs one Cursor turn as a permission-gated tool call. Your primary opencode model hands off a -discrete subtask and gets the result back. +Runs one Cursor turn in a permission-gated, parent-linked opencode child session. Your primary +opencode model hands off a discrete subtask and gets the result back while the child session +shows Cursor's live tool activity and remains available for later inspection. | Arg | Required | Meaning | | --- | --- | --- | diff --git a/src/plugin/cursor-tools.ts b/src/plugin/cursor-tools.ts index 7153ce1..3f95824 100644 --- a/src/plugin/cursor-tools.ts +++ b/src/plugin/cursor-tools.ts @@ -1,10 +1,17 @@ -import { tool, type ToolContext, type ToolDefinition } from "@opencode-ai/plugin"; +import { + tool, + type PluginInput, + type ToolContext, + type ToolDefinition, +} from "@opencode-ai/plugin"; import { runCloudAgent } from "../provider/cloud-agent.js"; -import { runDelegate } from "../provider/delegate.js"; +import { PROVIDER_ID } from "./model-v2.js"; const s = tool.schema; export interface CursorToolDeps { + /** opencode client used to create and prompt parent-linked child sessions. */ + client: PluginInput["client"]; /** * Resolve the Cursor API key (from opencode auth, captured by the plugin's * auth loader, or the CURSOR_API_KEY env var). Returns undefined when no key @@ -13,6 +20,18 @@ export interface CursorToolDeps { resolveApiKey: () => string | undefined; /** Default working directory for local delegation (the session worktree/cwd). */ defaultCwd: () => string; + /** Register Cursor controls for the next provider turn in a child session. */ + setDelegateControls: (sessionID: string, controls: CursorDelegateControls) => void; + /** Remove controls after the child provider turn settles. */ + clearDelegateControls: (sessionID: string) => void; +} + +export interface CursorDelegateControls { + mode: "agent" | "plan"; + cwd: string; + thinking?: string; + sandbox?: boolean; + agentId?: string; } const NEEDS_AUTH = @@ -48,6 +67,63 @@ function errorMessage(err: unknown): string { return err instanceof Error ? err.message : String(err); } +function delegateTitle(prompt: string): string { + const preview = prompt.replace(/\s+/g, " ").trim().slice(0, 80); + return `Cursor delegate: ${preview || "task"}`; +} + +/** + * Await a promise but reject early if `signal` aborts. When abort wins the + * race, a value that arrives afterward is routed to `onLateValue` so the caller + * can dispose of a resource (e.g. abort a child session that was created after + * we already gave up waiting). + */ +function awaitWithAbort( + promise: Promise, + signal: AbortSignal, + onLateValue?: (value: T) => void, +): Promise { + return new Promise((resolve, reject) => { + let settled = false; + const cleanup = () => signal.removeEventListener("abort", onAbort); + const onAbort = () => { + if (settled) return; + settled = true; + cleanup(); + reject(new Error("Delegation aborted")); + }; + + if (signal.aborted) { + // Already aborted: reject now, but still consume the in-flight promise so + // a resource it produces is disposed and its rejection stays handled. + void promise.then( + (value) => onLateValue?.(value), + () => {}, + ); + onAbort(); + return; + } + signal.addEventListener("abort", onAbort, { once: true }); + void promise.then( + (value) => { + if (settled) { + onLateValue?.(value); + return; + } + settled = true; + cleanup(); + resolve(value); + }, + (error: unknown) => { + if (settled) return; + settled = true; + cleanup(); + reject(error); + }, + ); + }); +} + /** * Build the Cursor delegation tools that complement the native provider: * - `cursor_cloud_agent`: run a background agent on a remote repo (optionally @@ -145,7 +221,9 @@ export function buildCursorTools(deps: CursorToolDeps): Record { + if (!childSessionID) return; + void Promise.resolve( + deps.client.session.abort({ + path: { id: childSessionID }, + query: { directory }, + }), + ).catch(() => {}); + }; + context.abort.addEventListener("abort", onAbort); + try { - result = await runDelegate({ - apiKey, - prompt: args.prompt, - model: args.model, - cwd: args.cwd ?? context.directory ?? deps.defaultCwd(), - ...(args.mode ? { mode: args.mode } : {}), + if (context.abort.aborted) throw new Error("Delegation aborted"); + + const creating = deps.client.session.create({ + body: { + parentID: context.sessionID, + title: delegateTitle(args.prompt), + }, + query: { directory }, + }); + // If abort wins the race, the child may still be created after we + // reject. It was never prompted, so there is nothing to abort — delete + // it so no empty orphan session lingers in the parent's session tree. + const created = await awaitWithAbort(creating, context.abort, (late) => { + if (!late.data?.id) return; + void Promise.resolve( + deps.client.session.delete({ + path: { id: late.data.id }, + query: { directory }, + }), + ).catch(() => {}); + }); + if (!created.data) { + throw new Error(`Could not create child session: ${errorMessage(created.error)}`); + } + childSessionID = created.data.id; + deps.setDelegateControls(childSessionID, { + mode: args.mode ?? "agent", + cwd: directory, ...(args.thinking ? { thinking: args.thinking } : {}), ...(args.sandbox !== undefined ? { sandbox: args.sandbox } : {}), ...(args.agentId ? { agentId: args.agentId } : {}), - abortSignal: context.abort, }); + + const prompting = deps.client.session.prompt({ + path: { id: childSessionID }, + query: { directory }, + body: { + model: { providerID: PROVIDER_ID, modelID: args.model }, + ...(args.mode === "plan" ? { agent: "plan" } : {}), + tools: { cursor_delegate: false }, + parts: [{ type: "text", text: args.prompt }], + }, + }); + const prompted = await awaitWithAbort(prompting, context.abort); + if (!prompted.data) { + throw new Error(`Child session failed: ${errorMessage(prompted.error)}`); + } + + const text = prompted.data.parts.reduce( + (output, part) => (part.type === "text" ? output + part.text : output), + "", + ); + + return { + title: `Cursor delegate (${args.model})`, + output: text || "(no text output)", + metadata: { + childSessionID, + model: args.model, + status: "finished", + usage: prompted.data.info.tokens ?? null, + }, + }; } catch (err) { return `Delegation failed: ${errorMessage(err)}`; + } finally { + context.abort.removeEventListener("abort", onAbort); + if (childSessionID) deps.clearDelegateControls(childSessionID); } - - const toolNote = - result.toolActivity.length > 0 - ? `\n\n(${result.toolActivity.length} tool call(s)` + - `${result.toolActivity.some((t) => t.isError) ? ", some failed" : ""})` - : ""; - - return { - title: `Cursor delegate (${args.model})`, - output: (result.text || "(no text output)") + toolNote, - metadata: { - agentId: result.agentId, - model: args.model, - toolCalls: result.toolActivity.length, - usage: result.usage ?? null, - }, - }; }, }), }; diff --git a/src/plugin/index.ts b/src/plugin/index.ts index 8536d6a..324aa8b 100644 --- a/src/plugin/index.ts +++ b/src/plugin/index.ts @@ -9,7 +9,10 @@ import { type McpStatusMap, translateMcpServers, } from "./mcp-config.js"; -import { buildCursorTools } from "./cursor-tools.js"; +import { + buildCursorTools, + type CursorDelegateControls, +} from "./cursor-tools.js"; import { warnIfStale } from "../version-check.js"; import { removeSystemRule } from "../provider/system-rule.js"; @@ -59,6 +62,9 @@ export const CursorPlugin: Plugin = async (input) => { // OAuth servers we've already warned about, so the toast fires once per // server rather than on every turn. const warnedOAuth = new Set(); + // Controls for a cursor_delegate child are scoped to that child session and + // live only while its prompt is running. + const delegateControls = new Map(); return { auth: { @@ -164,6 +170,17 @@ export const CursorPlugin: Plugin = async (input) => { if (input.agent === "plan" && output.options["mode"] === undefined) { output.options["mode"] = "plan"; } + const controls = delegateControls.get(input.sessionID); + if (controls) { + output.options = { + ...(output.options ?? {}), + mode: controls.mode, + cwd: controls.cwd, + ...(controls.thinking ? { thinking: controls.thinking } : {}), + ...(controls.sandbox !== undefined ? { sandbox: controls.sandbox } : {}), + ...(controls.agentId ? { agentId: controls.agentId } : {}), + }; + } // Dynamically re-forward MCP servers from opencode's *live* state so // mid-session enable/disable reaches the Cursor agent (the config hook @@ -239,8 +256,15 @@ export const CursorPlugin: Plugin = async (input) => { // and a permission-gated local delegate. They resolve the Cursor key from // the auth loader (captured above) or CURSOR_API_KEY. ...buildCursorTools({ + client: input.client, resolveApiKey: () => resolveCursorApiKey(capturedApiKey), defaultCwd: () => input?.directory ?? process.cwd(), + setDelegateControls: (sessionID, controls) => { + delegateControls.set(sessionID, controls); + }, + clearDelegateControls: (sessionID) => { + delegateControls.delete(sessionID); + }, }), }, diff --git a/src/provider/delegate.ts b/src/provider/delegate.ts deleted file mode 100644 index 5e61c30..0000000 --- a/src/provider/delegate.ts +++ /dev/null @@ -1,119 +0,0 @@ -import type { AgentModeOption } from "@cursor/sdk"; -import type { CursorUsage } from "./agent-events.js"; -import { streamAgentTurn } from "./agent-events.js"; -import { resolveControls } from "./controls.js"; -import { acquireAgent } from "./session-pool.js"; - -export interface DelegateParams { - apiKey: string; - /** The subtask to delegate to the Cursor agent. */ - prompt: string; - /** Cursor model id to run the delegation on. */ - model: string; - /** Conversation mode; defaults to "agent". */ - mode?: AgentModeOption; - /** Convenience for the Cursor `thinking` model param (e.g. "high"). */ - thinking?: string; - /** Working directory the local agent operates in. */ - cwd: string; - /** Run the agent's tools inside Cursor's sandbox. */ - sandbox?: boolean; - /** Resume a specific Cursor agent by id instead of creating a fresh one. */ - agentId?: string; - /** Cancels the run when aborted (wired to the tool's abort signal). */ - abortSignal?: AbortSignal; -} - -export interface DelegateToolActivity { - name: string; - isError: boolean; -} - -export interface DelegateResult { - agentId: string; - text: string; - reasoning: string; - toolActivity: DelegateToolActivity[]; - usage?: CursorUsage; -} - -/** - * Run a single delegated turn on a fresh (or explicitly resumed) local Cursor - * agent and aggregate the outcome into a plain result. This backs the opt-in - * `cursor_delegate` tool, which gives users a permission-gated boundary around - * Cursor (the provider path runs Cursor's own loop without per-call gating). - * - * Reuses the provider's `acquireAgent` + `streamAgentTurn` plumbing; the turn - * is consumed eagerly here because a tool returns a single result rather than a - * live stream. - */ -export async function runDelegate( - params: DelegateParams, -): Promise { - const { mode, modelSelection } = resolveControls( - params.model, - { - mode: params.mode ?? "agent", - ...(params.thinking ? { params: { thinking: params.thinking } } : {}), - }, - undefined, - ); - - const acquired = await acquireAgent({ - apiKey: params.apiKey, - modelSelection, - mode, - cwd: params.cwd, - ...(params.sandbox !== undefined ? { sandbox: params.sandbox } : {}), - ...(params.agentId ? { resumeAgentId: params.agentId } : {}), - }); - - const text: string[] = []; - const reasoning: string[] = []; - const toolActivity: DelegateToolActivity[] = []; - let usage: CursorUsage | undefined; - - try { - for await (const event of streamAgentTurn( - acquired.agent, - { text: params.prompt }, - { - mode, - ...(params.abortSignal ? { abortSignal: params.abortSignal } : {}), - }, - )) { - switch (event.type) { - case "text-delta": - text.push(event.text); - break; - case "reasoning-delta": - reasoning.push(event.text); - break; - case "tool-call": - toolActivity.push({ name: event.name, isError: false }); - break; - case "tool-result": - if (event.isError) - toolActivity.push({ name: event.name, isError: true }); - break; - case "usage": - usage = event.usage; - break; - case "finish": - // The aggregated result text; prefer it when deltas were absent. - if (event.text && text.length === 0) text.push(event.text); - break; - } - } - } finally { - acquired.release(); - } - - return { - agentId: acquired.agent.agentId, - text: text.join(""), - reasoning: reasoning.join(""), - toolActivity, - ...(usage ? { usage } : {}), - }; -} diff --git a/src/provider/language-model.ts b/src/provider/language-model.ts index 60261fb..b9ad9c6 100644 --- a/src/provider/language-model.ts +++ b/src/provider/language-model.ts @@ -164,6 +164,14 @@ export class CursorLanguageModel implements LanguageModelV3 { typeof providerOptions?.["agentId"] === "string" ? (providerOptions["agentId"] as string) : undefined; + const sandbox = + typeof providerOptions?.["sandbox"] === "boolean" + ? providerOptions["sandbox"] + : this.config.sandbox; + const cwd = + typeof providerOptions?.["cwd"] === "string" + ? providerOptions["cwd"] + : this.config.cwd; // The plugin may mark opencode's non-chat side calls (e.g. title // generation) so they never resume or disturb the pooled agent. const ephemeral = providerOptions?.["ephemeral"] === true; @@ -259,7 +267,7 @@ export class CursorLanguageModel implements LanguageModelV3 { const delivery = resolveSystemDelivery({ mode: this.config.systemPrompt ?? "rules", settingSources: this.config.settingSources, - cwd: this.config.cwd, + cwd, systemText: extractSystemText(options.prompt), warn: (message) => this.warnOnce(message), }); @@ -273,10 +281,10 @@ export class CursorLanguageModel implements LanguageModelV3 { apiKey: this.requireApiKey(), modelSelection, mode, - cwd: this.config.cwd, + cwd, ...(settingSources ? { settingSources } : {}), - ...(this.config.sandbox !== undefined - ? { sandbox: this.config.sandbox } + ...(sandbox !== undefined + ? { sandbox } : {}), ...(mcpServers ? { mcpServers } : {}), ...(this.config.agents ? { agents: this.config.agents } : {}), diff --git a/test/cursor-tools.test.ts b/test/cursor-tools.test.ts index ee80a58..7ea8709 100644 --- a/test/cursor-tools.test.ts +++ b/test/cursor-tools.test.ts @@ -1,43 +1,67 @@ import { afterEach, describe, expect, it, vi } from "vitest"; const runCloudAgent = vi.fn(); -const runDelegate = vi.fn(); vi.mock("../src/provider/cloud-agent.js", () => ({ runCloudAgent })); -vi.mock("../src/provider/delegate.js", () => ({ runDelegate })); const { buildCursorTools } = await import("../src/plugin/cursor-tools.js"); -function ctx(ask: ReturnType) { +function ctx(ask: ReturnType, controller = new AbortController()) { return { ask, - abort: new AbortController().signal, + abort: controller.signal, directory: "/work", worktree: "/work", - sessionID: "s", + sessionID: "parent-1", messageID: "m", agent: "a", metadata: vi.fn(), } as any; } -const withKey = { resolveApiKey: () => "k", defaultCwd: () => "/work" }; -const noKey = { resolveApiKey: () => undefined, defaultCwd: () => "/work" }; +function childClient() { + return { + session: { + create: vi.fn().mockResolvedValue({ data: { id: "child-1" } }), + prompt: vi.fn().mockResolvedValue({ + data: { + info: { + tokens: { input: 2, output: 3, reasoning: 1, cache: { read: 0, write: 0 } }, + }, + parts: [{ type: "text", text: "child result" }], + }, + }), + abort: vi.fn().mockResolvedValue({ data: {} }), + delete: vi.fn().mockResolvedValue({ data: {} }), + }, + }; +} + +function deps(overrides: Record = {}) { + return { + resolveApiKey: () => "k", + defaultCwd: () => "/work", + client: childClient(), + setDelegateControls: vi.fn(), + clearDelegateControls: vi.fn(), + ...overrides, + } as any; +} afterEach(() => { runCloudAgent.mockReset(); - runDelegate.mockReset(); }); describe("buildCursorTools", () => { it("returns a needs-auth message when no API key is available", async () => { - const tools = buildCursorTools(noKey); + const dependency = deps({ resolveApiKey: () => undefined }); + const tools = buildCursorTools(dependency); const out = await tools.cursor_delegate!.execute( { prompt: "p", model: "m" } as any, ctx(vi.fn().mockResolvedValue(undefined)), ); expect(String(out)).toContain("No Cursor API key"); - expect(runDelegate).not.toHaveBeenCalled(); + expect(dependency.client.session.create).not.toHaveBeenCalled(); }); it("runs the cloud agent after approval and surfaces the PR url", async () => { @@ -51,7 +75,7 @@ describe("buildCursorTools", () => { progress: ["status: finished"], }); const ask = vi.fn().mockResolvedValue(undefined); - const tools = buildCursorTools(withKey); + const tools = buildCursorTools(deps()); const out = (await tools.cursor_cloud_agent!.execute( { prompt: "p", repoUrl: "https://github.com/o/r", autoCreatePR: true } as any, @@ -59,83 +83,171 @@ describe("buildCursorTools", () => { )) as { output: string; metadata: Record }; expect(ask).toHaveBeenCalledOnce(); - const callArgs = runCloudAgent.mock.calls[0]![0]; - expect(callArgs.apiKey).toBe("k"); - expect(callArgs.abortSignal).toBeDefined(); + expect(runCloudAgent.mock.calls[0]![0].apiKey).toBe("k"); expect(out.output).toContain("PR: https://github.com/o/r/pull/1"); expect(out.metadata.agentId).toBe("bc-1"); }); it("blocks delegation when the permission gate denies it", async () => { - const ask = vi.fn().mockRejectedValue(new Error("denied")); - const tools = buildCursorTools(withKey); - + const dependency = deps(); + const tools = buildCursorTools(dependency); const out = await tools.cursor_delegate!.execute( { prompt: "p", model: "m" } as any, - ctx(ask), + ctx(vi.fn().mockRejectedValue(new Error("denied"))), ); expect(String(out)).toContain("not approved"); expect(String(out)).toContain("denied"); - expect(runDelegate).not.toHaveBeenCalled(); + expect(dependency.client.session.create).not.toHaveBeenCalled(); }); - it("delegates after approval and returns the agent text", async () => { - runDelegate.mockResolvedValue({ - agentId: "a1", - text: "result text", - reasoning: "", - toolActivity: [{ name: "read", isError: false }], - usage: undefined, - }); - const ask = vi.fn().mockResolvedValue(undefined); - const tools = buildCursorTools(withKey); + it("creates and prompts a parent-linked Cursor child session", async () => { + const dependency = deps(); + const tools = buildCursorTools(dependency); const out = (await tools.cursor_delegate!.execute( - { prompt: "p", model: "m", thinking: "high" } as any, - ctx(ask), + { prompt: "inspect files", model: "composer-2.5", mode: "plan", thinking: "high" } as any, + ctx(vi.fn().mockResolvedValue(undefined)), )) as { output: string; metadata: Record }; - const callArgs = runDelegate.mock.calls[0]![0]; - expect(callArgs).toMatchObject({ apiKey: "k", model: "m", thinking: "high", cwd: "/work" }); - expect(callArgs.abortSignal).toBeDefined(); - expect(out.output).toContain("result text"); - expect(out.output).toContain("1 tool call"); - expect(out.metadata.agentId).toBe("a1"); + expect(dependency.client.session.create).toHaveBeenCalledWith({ + body: { parentID: "parent-1", title: "Cursor delegate: inspect files" }, + query: { directory: "/work" }, + }); + expect(dependency.client.session.prompt).toHaveBeenCalledWith({ + path: { id: "child-1" }, + query: { directory: "/work" }, + body: { + model: { providerID: "cursor", modelID: "composer-2.5" }, + agent: "plan", + tools: { cursor_delegate: false }, + parts: [{ type: "text", text: "inspect files" }], + }, + }); + expect(dependency.setDelegateControls).toHaveBeenCalledWith("child-1", { + mode: "plan", + cwd: "/work", + thinking: "high", + }); + expect(dependency.clearDelegateControls).toHaveBeenCalledWith("child-1"); + expect(out.output).toBe("child result"); + expect(out.metadata).toMatchObject({ + childSessionID: "child-1", + model: "composer-2.5", + status: "finished", + }); }); - it("returns needs-auth for the cloud agent tool when no API key is available", async () => { - const tools = buildCursorTools(noKey); - const out = await tools.cursor_cloud_agent!.execute( - { prompt: "p", repoUrl: "https://github.com/o/r" } as any, + it("forwards cwd, sandbox, and agentId into the child controls", async () => { + const dependency = deps(); + const tools = buildCursorTools(dependency); + + await tools.cursor_delegate!.execute( + { + prompt: "p", + model: "m", + cwd: "/explicit", + sandbox: true, + agentId: "cursor-agent", + } as any, ctx(vi.fn().mockResolvedValue(undefined)), ); - expect(String(out)).toContain("No Cursor API key"); - expect(runCloudAgent).not.toHaveBeenCalled(); + + expect(dependency.client.session.create).toHaveBeenCalledWith({ + body: { parentID: "parent-1", title: "Cursor delegate: p" }, + query: { directory: "/explicit" }, + }); + expect(dependency.setDelegateControls).toHaveBeenCalledWith("child-1", { + mode: "agent", + cwd: "/explicit", + sandbox: true, + agentId: "cursor-agent", + }); }); - it("blocks the cloud agent when the permission gate denies it", async () => { - const ask = vi.fn().mockRejectedValue(new Error("denied")); - const tools = buildCursorTools(withKey); + it("returns a sanitized failure when the child prompt errors", async () => { + const dependency = deps(); + dependency.client.session.prompt.mockRejectedValue(new Error("agent crashed")); + const tools = buildCursorTools(dependency); - const out = await tools.cursor_cloud_agent!.execute( - { prompt: "p", repoUrl: "https://github.com/o/r" } as any, - ctx(ask), + const out = await tools.cursor_delegate!.execute( + { prompt: "p", model: "m" } as any, + ctx(vi.fn().mockResolvedValue(undefined)), ); - expect(String(out)).toContain("not approved"); - expect(String(out)).toContain("denied"); - expect(runCloudAgent).not.toHaveBeenCalled(); + expect(String(out)).toContain("Delegation failed"); + expect(String(out)).toContain("agent crashed"); + expect(dependency.clearDelegateControls).toHaveBeenCalledWith("child-1"); }); - it("fails closed when the host does not expose context.ask", async () => { - const tools = buildCursorTools(withKey); - // context with no `ask` function (e.g. an unexpected/older host). + it("aborts the child session when the tool aborts", async () => { + const dependency = deps(); + let resolvePrompt!: (value: unknown) => void; + dependency.client.session.prompt.mockReturnValue( + new Promise((resolve) => { + resolvePrompt = resolve; + }), + ); + const controller = new AbortController(); + const tools = buildCursorTools(dependency); + const pending = tools.cursor_delegate!.execute( + { prompt: "p", model: "m" } as any, + ctx(vi.fn().mockResolvedValue(undefined), controller), + ); + + await vi.waitFor(() => expect(dependency.client.session.prompt).toHaveBeenCalled()); + controller.abort(); + await vi.waitFor(() => + expect(dependency.client.session.abort).toHaveBeenCalledWith({ + path: { id: "child-1" }, + query: { directory: "/work" }, + }), + ); + resolvePrompt({ data: { info: {}, parts: [{ type: "text", text: "done" }] } }); + expect(String(await pending)).toContain("Delegation failed"); + }); + + it("deletes a child session created after the tool has aborted", async () => { + const dependency = deps(); + let resolveCreate!: (value: unknown) => void; + dependency.client.session.create.mockReturnValue( + new Promise((resolve) => { + resolveCreate = resolve; + }), + ); + const controller = new AbortController(); + const tools = buildCursorTools(dependency); + const pending = tools.cursor_delegate!.execute( + { prompt: "p", model: "m" } as any, + ctx(vi.fn().mockResolvedValue(undefined), controller), + ); + + await vi.waitFor(() => expect(dependency.client.session.create).toHaveBeenCalled()); + controller.abort(); + // The child resolves only after abort won the race. It was never prompted, + // so it must be deleted (not aborted) to avoid an empty orphan session. + resolveCreate({ data: { id: "late-child" } }); + + const out = await pending; + expect(String(out)).toContain("Delegation failed"); + expect(dependency.client.session.prompt).not.toHaveBeenCalled(); + await vi.waitFor(() => + expect(dependency.client.session.delete).toHaveBeenCalledWith({ + path: { id: "late-child" }, + query: { directory: "/work" }, + }), + ); + expect(dependency.client.session.abort).not.toHaveBeenCalled(); + }); + + it("fails closed for delegation when the host does not expose context.ask", async () => { + const dependency = deps(); + const tools = buildCursorTools(dependency); const context = { abort: new AbortController().signal, directory: "/work", worktree: "/work", - sessionID: "s", + sessionID: "parent-1", messageID: "m", agent: "a", metadata: vi.fn(), @@ -143,50 +255,29 @@ describe("buildCursorTools", () => { const out = await tools.cursor_delegate!.execute({ prompt: "p", model: "m" } as any, context); - // No silent allow: a missing gate blocks rather than running unsupervised. expect(String(out)).toContain("not approved"); - expect(runDelegate).not.toHaveBeenCalled(); + expect(dependency.client.session.create).not.toHaveBeenCalled(); }); - it("renders '(no text output)' and a failed-tool note for an empty delegate result", async () => { - runDelegate.mockResolvedValue({ - agentId: "a9", - text: "", - reasoning: "", - toolActivity: [ - { name: "read", isError: false }, - { name: "write", isError: true }, - ], - usage: undefined, - }); - const tools = buildCursorTools(withKey); - - const out = (await tools.cursor_delegate!.execute( - { prompt: "p", model: "m" } as any, + it("returns needs-auth for the cloud agent tool when no API key is available", async () => { + const tools = buildCursorTools(deps({ resolveApiKey: () => undefined })); + const out = await tools.cursor_cloud_agent!.execute( + { prompt: "p", repoUrl: "https://github.com/o/r" } as any, ctx(vi.fn().mockResolvedValue(undefined)), - )) as { output: string; metadata: Record }; - - expect(out.output).toContain("(no text output)"); - expect(out.output).toContain("2 tool call(s)"); - expect(out.output).toContain("some failed"); - expect(out.metadata.toolCalls).toBe(2); + ); + expect(String(out)).toContain("No Cursor API key"); + expect(runCloudAgent).not.toHaveBeenCalled(); }); - it("resolves cwd from args > context.directory > defaultCwd for delegation", async () => { - runDelegate.mockResolvedValue({ - agentId: "a1", - text: "x", - reasoning: "", - toolActivity: [], - usage: undefined, - }); - const tools = buildCursorTools(withKey); - - await tools.cursor_delegate!.execute( - { prompt: "p", model: "m", cwd: "/explicit" } as any, - ctx(vi.fn().mockResolvedValue(undefined)), + it("blocks the cloud agent when the permission gate denies it", async () => { + const tools = buildCursorTools(deps()); + const out = await tools.cursor_cloud_agent!.execute( + { prompt: "p", repoUrl: "https://github.com/o/r" } as any, + ctx(vi.fn().mockRejectedValue(new Error("denied"))), ); - expect(runDelegate.mock.calls[0]![0].cwd).toBe("/explicit"); + expect(String(out)).toContain("not approved"); + expect(String(out)).toContain("denied"); + expect(runCloudAgent).not.toHaveBeenCalled(); }); it("formats the cloud output with branches and progress when no PR is opened", async () => { @@ -199,7 +290,7 @@ describe("buildCursorTools", () => { durationMs: 5, progress: ["status: running", "status: finished"], }); - const tools = buildCursorTools(withKey); + const tools = buildCursorTools(deps()); const out = (await tools.cursor_cloud_agent!.execute( { prompt: "p", repoUrl: "https://github.com/o/r" } as any, @@ -209,14 +300,13 @@ describe("buildCursorTools", () => { expect(out.output).not.toContain("PR:"); expect(out.output).toContain("Branches: feat-x"); expect(out.output).toContain("Progress:"); - expect(out.output).toContain("status: finished"); expect(out.output).toContain("summary text"); expect(out.metadata.prUrl).toBeNull(); }); it("returns a sanitized failure (not a throw) when the cloud run errors", async () => { runCloudAgent.mockRejectedValue(new Error("network down")); - const tools = buildCursorTools(withKey); + const tools = buildCursorTools(deps()); const out = await tools.cursor_cloud_agent!.execute( { prompt: "p", repoUrl: "https://github.com/o/r" } as any, @@ -226,17 +316,4 @@ describe("buildCursorTools", () => { expect(String(out)).toContain("Cloud agent failed"); expect(String(out)).toContain("network down"); }); - - it("returns a sanitized failure (not a throw) when delegation errors", async () => { - runDelegate.mockRejectedValue(new Error("agent crashed")); - const tools = buildCursorTools(withKey); - - const out = await tools.cursor_delegate!.execute( - { prompt: "p", model: "m" } as any, - ctx(vi.fn().mockResolvedValue(undefined)), - ); - - expect(String(out)).toContain("Delegation failed"); - expect(String(out)).toContain("agent crashed"); - }); }); diff --git a/test/delegate.test.ts b/test/delegate.test.ts deleted file mode 100644 index 5e97e26..0000000 --- a/test/delegate.test.ts +++ /dev/null @@ -1,154 +0,0 @@ -import { afterEach, describe, expect, it, vi } from "vitest"; - -const create = vi.fn(); -const resume = vi.fn(); - -vi.mock("../src/cursor-runtime.js", () => ({ - loadCursorSdk: async () => ({ Agent: { create, resume } }), -})); - -const { runDelegate } = await import("../src/provider/delegate.js"); - -type Update = { type: string } & Record; - -function fakeAgent(agentId: string, deltas: Update[], result: unknown) { - return { - agentId, - close: vi.fn(), - send: async (_msg: unknown, opts: { onDelta?: (a: { update: Update }) => void }) => { - for (const update of deltas) await opts.onDelta?.({ update }); - return { cancel: vi.fn().mockResolvedValue(undefined), wait: async () => result }; - }, - }; -} - -const deltas: Update[] = [ - { type: "text-delta", text: "Hel" }, - { type: "text-delta", text: "lo" }, - { type: "thinking-delta", text: "hmm" }, - { type: "tool-call-started", callId: "1", toolCall: { type: "read", args: {} } }, - { type: "tool-call-completed", callId: "1", toolCall: { type: "read", result: { status: "success" } } }, - { - type: "turn-ended", - usage: { inputTokens: 5, outputTokens: 3, cacheReadTokens: 0, cacheWriteTokens: 0 }, - }, -]; - -afterEach(() => { - create.mockReset(); - resume.mockReset(); -}); - -describe("runDelegate", () => { - it("aggregates text, reasoning, tool activity, and usage from a fresh agent", async () => { - create.mockResolvedValue(fakeAgent("a1", deltas, { status: "finished", result: "Hello" })); - - const r = await runDelegate({ apiKey: "k", prompt: "do", model: "m", cwd: "/tmp" }); - - expect(create).toHaveBeenCalledOnce(); - expect(r.agentId).toBe("a1"); - expect(r.text).toBe("Hello"); - expect(r.reasoning).toBe("hmm"); - expect(r.toolActivity).toEqual([{ name: "read", isError: false }]); - expect(r.usage).toEqual({ - inputTokens: 5, - outputTokens: 3, - cacheReadTokens: 0, - cacheWriteTokens: 0, - }); - const opts = create.mock.calls[0]![0] as any; - expect(opts.model).toEqual({ id: "m" }); - expect(opts.mode).toBe("agent"); - }); - - it("resumes an explicit agentId instead of creating", async () => { - resume.mockResolvedValue(fakeAgent("keep", [], { status: "finished", result: "ok" })); - - const r = await runDelegate({ - apiKey: "k", - prompt: "do", - model: "m", - cwd: "/tmp", - agentId: "keep", - }); - - expect(resume).toHaveBeenCalledWith("keep", expect.anything()); - expect(create).not.toHaveBeenCalled(); - expect(r.agentId).toBe("keep"); - expect(r.text).toBe("ok"); - }); - - it("maps the thinking convenience into a model param and plan mode", async () => { - create.mockResolvedValue(fakeAgent("a2", [], { status: "finished", result: "" })); - - await runDelegate({ - apiKey: "k", - prompt: "do", - model: "m", - cwd: "/tmp", - mode: "plan", - thinking: "high", - }); - - const opts = create.mock.calls[0]![0] as any; - expect(opts.mode).toBe("plan"); - expect(opts.model).toEqual({ id: "m", params: [{ id: "thinking", value: "high" }] }); - }); - - it("flags failed tool calls and does not double-count successful ones", async () => { - const mixed: Update[] = [ - { type: "tool-call-started", callId: "1", toolCall: { type: "read", args: {} } }, - { - type: "tool-call-completed", - callId: "1", - toolCall: { type: "read", result: { status: "success" } }, - }, - { type: "tool-call-started", callId: "2", toolCall: { type: "write", args: {} } }, - { - type: "tool-call-completed", - callId: "2", - toolCall: { type: "write", result: { status: "error" } }, - }, - ]; - create.mockResolvedValue(fakeAgent("a3", mixed, { status: "finished", result: "ok" })); - - const r = await runDelegate({ apiKey: "k", prompt: "do", model: "m", cwd: "/tmp" }); - - // read+write yield two tool-call entries; the failed write adds one more isError entry. - expect(r.toolActivity).toEqual([ - { name: "read", isError: false }, - { name: "write", isError: false }, - { name: "write", isError: true }, - ]); - }); - - it("falls back to the finish result text when no text deltas were emitted", async () => { - create.mockResolvedValue( - fakeAgent("a4", [{ type: "thinking-delta", text: "thinking only" }], { - status: "finished", - result: "final answer", - }), - ); - - const r = await runDelegate({ apiKey: "k", prompt: "do", model: "m", cwd: "/tmp" }); - expect(r.text).toBe("final answer"); - expect(r.reasoning).toBe("thinking only"); - }); - - it("releases the agent and propagates when the turn fails", async () => { - const close = vi.fn(); - create.mockResolvedValue({ - agentId: "a5", - close, - send: async () => { - throw new Error("send failed"); - }, - }); - - await expect( - runDelegate({ apiKey: "k", prompt: "do", model: "m", cwd: "/tmp" }), - ).rejects.toThrow("send failed"); - // Non-pooled (session:false) agents must be closed by release() even on failure. - expect(close).toHaveBeenCalled(); - }); -}); diff --git a/test/language-model.test.ts b/test/language-model.test.ts index de56788..8b1eafa 100644 --- a/test/language-model.test.ts +++ b/test/language-model.test.ts @@ -114,6 +114,38 @@ afterEach(() => { }); describe("CursorLanguageModel doStream — resume-aware retry", () => { + it("forwards a per-turn sandbox override to Cursor agent creation", async () => { + const model = makeModel(); + create.mockResolvedValueOnce(fakeAgent({ agentId: "sandboxed" })); + + await collectStream( + streamCall(model, { + prompt: [sys("S"), user("run")], + providerOptions: { cursor: { sessionID: "sandbox-session", sandbox: true } }, + } as never), + ); + + expect(create.mock.calls[0]![0]).toMatchObject({ + local: { sandboxOptions: { enabled: true } }, + }); + }); + + it("forwards a per-turn cwd override to Cursor agent creation", async () => { + const model = makeModel(); + create.mockResolvedValueOnce(fakeAgent({ agentId: "scoped" })); + + await collectStream( + streamCall(model, { + prompt: [sys("S"), user("run")], + providerOptions: { cursor: { sessionID: "cwd-session", cwd: "/child/dir" } }, + } as never), + ); + + expect(create.mock.calls[0]![0]).toMatchObject({ + local: { cwd: "/child/dir" }, + }); + }); + it("re-creates a fresh agent + full transcript when a resumed turn errors before emitting", async () => { const model = makeModel(); const firstSent: SDKUserMessage[] = []; @@ -359,4 +391,4 @@ describe("CursorLanguageModel doStream — resume-aware retry", () => { // Original resume failure preserved as the cause for diagnosability. expect((error.cause as Error)?.message).toContain("error"); }); -}); \ No newline at end of file +}); diff --git a/test/plugin-tools.test.ts b/test/plugin-tools.test.ts index 16adfaa..a90fbc3 100644 --- a/test/plugin-tools.test.ts +++ b/test/plugin-tools.test.ts @@ -1,11 +1,9 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; const runCloudAgent = vi.fn(); -const runDelegate = vi.fn(); // Mock the delegation runtimes so the tool hooks never touch the Cursor SDK. vi.mock("../src/provider/cloud-agent.js", () => ({ runCloudAgent })); -vi.mock("../src/provider/delegate.js", () => ({ runDelegate })); const { default: plugin } = await import("../src/plugin/index.js"); @@ -22,6 +20,21 @@ function ctx(ask: ReturnType) { } as any; } +function fakeClient() { + return { + session: { + create: vi.fn().mockResolvedValue({ data: { id: "child-1" } }), + prompt: vi.fn().mockResolvedValue({ + data: { info: {}, parts: [{ type: "text", text: "done" }] }, + }), + abort: vi.fn().mockResolvedValue({ data: {} }), + }, + config: { get: vi.fn().mockResolvedValue({ data: { mcp: {} } }) }, + mcp: { status: vi.fn().mockResolvedValue({ data: {} }) }, + tui: { showToast: vi.fn() }, + }; +} + let savedEnvKey: string | undefined; beforeEach(() => { @@ -33,8 +46,7 @@ beforeEach(() => { afterEach(() => { if (savedEnvKey === undefined) delete process.env.CURSOR_API_KEY; else process.env.CURSOR_API_KEY = savedEnvKey; - runCloudAgent.mockReset(); - runDelegate.mockReset(); + runCloudAgent.mockReset(); }); describe("CursorPlugin tool hook", () => { @@ -53,18 +65,41 @@ describe("CursorPlugin tool hook", () => { ctx(vi.fn().mockResolvedValue(undefined)), ); expect(String(out)).toContain("No Cursor API key"); - expect(runDelegate).not.toHaveBeenCalled(); }); it("feeds the auth-loader-captured key into the delegation tools", async () => { - runDelegate.mockResolvedValue({ - agentId: "a1", - text: "done", - reasoning: "", - toolActivity: [], - usage: undefined, - }); - const hooks = await plugin({ directory: "/work" } as never); + const client = fakeClient(); + const hooks = await plugin({ directory: "/work", client } as never); + let childOptions: Record | undefined; + let siblingOptions: Record | undefined; + client.session.prompt.mockImplementation(async ({ path }: any) => { + const output = { options: {} } as any; + await hooks["chat.params"]!( + { + sessionID: path.id, + agent: "build", + model: { providerID: "cursor", modelID: "m" }, + provider: {}, + message: {}, + } as never, + output, + ); + childOptions = output.options; + // A sibling session must NOT inherit the delegate controls. + const other = { options: {} } as any; + await hooks["chat.params"]!( + { + sessionID: "unrelated", + agent: "build", + model: { providerID: "cursor", modelID: "m" }, + provider: {}, + message: {}, + } as never, + other, + ); + siblingOptions = other.options; + return { data: { info: {}, parts: [{ type: "text", text: "done" }] } } as any; + }); // Simulate opencode's auth loader running with a stored API key. const loaded = await hooks.auth!.loader!(async () => ({ type: "api", key: "sekret" }) as never, { @@ -74,11 +109,22 @@ describe("CursorPlugin tool hook", () => { // The tool should now resolve the captured key rather than returning needs-auth. await hooks.tool!.cursor_delegate!.execute( - { prompt: "p", model: "m" } as any, + { prompt: "p", model: "m", thinking: "high", sandbox: true, agentId: "cursor-agent" } as any, ctx(vi.fn().mockResolvedValue(undefined)), ); - expect(runDelegate).toHaveBeenCalledOnce(); - expect(runDelegate.mock.calls[0]![0].apiKey).toBe("sekret"); + expect(client.session.create).toHaveBeenCalledOnce(); + expect(client.session.prompt).toHaveBeenCalledOnce(); + expect(childOptions).toMatchObject({ + mode: "agent", + thinking: "high", + sandbox: true, + agentId: "cursor-agent", + sessionID: "child-1", + }); + // Controls are keyed per child session, so an unrelated session never + // inherits them (verified here while the child prompt is still pending). + expect(siblingOptions).not.toHaveProperty("thinking"); + expect(siblingOptions).not.toHaveProperty("agentId"); }); it("falls back to CURSOR_API_KEY when the loader never captured a key", async () => {