From 0c41f64e1255dd871a5c4dc5f08b995d2b8d91a1 Mon Sep 17 00:00:00 2001 From: Justin Carper Date: Wed, 15 Jul 2026 12:55:22 -0500 Subject: [PATCH 1/2] feat(delegate): run cursor_delegate as a navigable child session cursor_delegate previously ran the Cursor SDK directly inside the tool and collapsed the turn into a single result block, so its tool calls, reasoning, and text were invisible in opencode. opencode exposes no API to inject externally-produced message parts into a session, so the only way to surface that activity is to let opencode drive the turn. The tool now creates a parent-linked child session (session.create with parentID = the calling session) and runs the delegated prompt through session.prompt against the cursor provider. opencode's existing provider pipeline streams the child turn, so Cursor's live activity renders in the navigable subagent UI and the child session persists for later inspection. - Forward per-turn controls (mode/thinking/sandbox/agentId/cwd) to only the matching child session via a session-keyed control map consumed by the chat.params hook; controls are cleared after the turn so siblings never inherit them. - Read per-turn sandbox and cwd from providerOptions.cursor so the child executes in the requested directory and sandbox (cwd now reaches acquireAgent, not just the session query). - Abort the in-flight child on tool abort and delete a child that finishes creation after abort won the race, so no empty orphan session lingers. - Remove the now-dead direct runDelegate runtime and its tests. 299 tests, typecheck, and build pass. --- CHANGELOG.md | 4 + README.md | 10 +- .../2026-07-15-cursor-delegate-subagent-ui.md | 346 ++++++++++++++++++ src/plugin/cursor-tools.ts | 184 ++++++++-- src/plugin/index.ts | 26 +- src/provider/delegate.ts | 119 ------ src/provider/language-model.ts | 16 +- test/cursor-tools.test.ts | 295 +++++++++------ test/delegate.test.ts | 154 -------- test/language-model.test.ts | 34 +- test/plugin-tools.test.ts | 78 +++- 11 files changed, 830 insertions(+), 436 deletions(-) create mode 100644 docs/superpowers/plans/2026-07-15-cursor-delegate-subagent-ui.md delete mode 100644 src/provider/delegate.ts delete mode 100644 test/delegate.test.ts 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/docs/superpowers/plans/2026-07-15-cursor-delegate-subagent-ui.md b/docs/superpowers/plans/2026-07-15-cursor-delegate-subagent-ui.md new file mode 100644 index 0000000..bbc1e7d --- /dev/null +++ b/docs/superpowers/plans/2026-07-15-cursor-delegate-subagent-ui.md @@ -0,0 +1,346 @@ +# Cursor Delegate Subagent UI Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Route `cursor_delegate` through a parent-linked opencode child session so Cursor activity appears live and remains navigable in the opencode subagent UI. + +**Architecture:** Keep the existing auth and permission gate. Replace the direct `runDelegate` call with `client.session.create({ parentID })` and blocking `client.session.prompt(...)`. Use a short-lived child-session control map consumed by the existing `chat.params` hook to forward Cursor-only per-turn controls that the SDK prompt schema cannot carry directly. + +**Tech Stack:** TypeScript, `@opencode-ai/plugin`, `@opencode-ai/sdk`, `@cursor/sdk`, Vitest, opencode V3 provider stream pipeline. + +> **Status: Implemented.** All tasks below are complete. `cursor_delegate` routes through a parent-linked child session; per-turn `mode`/`thinking`/`sandbox`/`cwd`/`agentId` are forwarded via `chat.params`; the direct `runDelegate` runtime is removed; 298 tests, typecheck, and build pass. The unchecked step boxes are retained as the historical build record. + +## Global Constraints + +- `cursor_delegate` is the scoped feature; cloud delegation and Cursor internal subagents remain unchanged. +- Permission approval occurs before child-session creation or Cursor execution. +- Child sessions persist after completion. +- No private TUI APIs or synthetic event injection. +- No silent fallback to the old collapsed direct-SDK result. +- Preserve `prompt`, `model`, `mode`, `thinking`, `cwd`, `sandbox`, and `agentId` semantics. + +--- + +### Task 1: Add Delegate Control Bridge + +**Files:** +- Modify: `src/plugin/index.ts` +- Modify: `src/provider/language-model.ts:138-290` +- Modify: `test/plugin-tools.test.ts` +- Modify: `test/language-model.test.ts` + +**Interfaces:** +- Produces `CursorDelegateControls` with `mode`, optional `thinking`, optional `sandbox`, and optional `agentId`. +- Produces plugin-local `Map` registration and cleanup callbacks passed to `buildCursorTools`. +- Makes `chat.params` merge registered controls into `providerOptions.cursor` for the matching child session. + +- [ ] **Step 1: Add failing plugin-hook tests** + +Extend the existing `CursorPlugin chat.params hook` tests with a child-session control case: + +```ts +it("applies registered delegate controls only to the matching child session", async () => { + let childOptions: Record | undefined; + const client = fakeClient(); + const hooks = await plugin({ directory: "/work", client } as never); + 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; + return { data: { info: {}, parts: [{ type: "text", text: "done" }] } } as any; + }); + + await hooks.tool!.cursor_delegate!.execute( + { prompt: "p", model: "m", thinking: "high", sandbox: true, agentId: "cursor-agent" } as any, + ctx(vi.fn().mockResolvedValue(undefined)), + ); + + expect(childOptions).toMatchObject({ + mode: "agent", + thinking: "high", + sandbox: true, + agentId: "cursor-agent", + sessionID: "child", + }); +}); +``` + +Use the same registration path production code uses; do not introduce a test-only hook. + +- [ ] **Step 2: Run the focused test and verify failure** + +Run: `npm test -- --run test/plugin-tools.test.ts` + +Expected: FAIL because no delegate control registration path exists yet. + +- [ ] **Step 3: Implement the control map** + +In `src/plugin/index.ts`, add a plugin-local map and callbacks: + +```ts +export interface CursorDelegateControls { + mode: "agent" | "plan"; + thinking?: string; + sandbox?: boolean; + agentId?: string; +} + +const delegateControls = new Map(); + +const setDelegateControls = (sessionID: string, controls: CursorDelegateControls) => { + delegateControls.set(sessionID, controls); +}; + +const clearDelegateControls = (sessionID: string) => { + delegateControls.delete(sessionID); +}; +``` + +In `chat.params`, after the existing plan-agent mapping, merge the matching entry: + +```ts +const controls = delegateControls.get(input.sessionID); +if (controls) { + output.options = { + ...(output.options ?? {}), + mode: controls.mode, + ...(controls.thinking ? { thinking: controls.thinking } : {}), + ...(controls.sandbox !== undefined ? { sandbox: controls.sandbox } : {}), + ...(controls.agentId ? { agentId: controls.agentId } : {}), + }; +} +``` + +Pass `setDelegateControls` and `clearDelegateControls` into `buildCursorTools`. Keep controls session-scoped and remove them after the prompt settles. + +- [ ] **Step 4: Add per-turn sandbox resolution** + +In `src/provider/language-model.ts`, resolve sandbox from provider options before constructing `baseAcquire`: + +```ts +const sandbox = + typeof providerOptions?.["sandbox"] === "boolean" + ? providerOptions["sandbox"] + : this.config.sandbox; +``` + +Use `sandbox` in the existing acquire spread instead of `this.config.sandbox`. Keep all other acquire behavior unchanged. + +- [ ] **Step 5: Add provider regression coverage** + +Extend the existing language-model mock assertions to verify that `providerOptions.cursor.sandbox` is forwarded to `acquireAgent`, while an omitted per-turn value still uses the static provider setting. Keep existing explicit `agentId` coverage and add `thinking`/mode assertions only where the current test harness already observes `resolveControls` inputs. + +- [ ] **Step 6: Run focused tests** + +Run: `npm test -- --run test/plugin-tools.test.ts test/language-model.test.ts` + +Expected: PASS. + +--- + +### Task 2: Route `cursor_delegate` Through Child Session + +**Files:** +- Modify: `src/plugin/cursor-tools.ts` +- Modify: `test/cursor-tools.test.ts` +- Modify: `src/plugin/index.ts` only if dependency wiring from Task 1 needs adjustment + +**Interfaces:** +- `CursorToolDeps` consumes `client`, `setDelegateControls`, and `clearDelegateControls`. +- `cursor_delegate.execute` creates a child session using `context.sessionID` and prompts it with provider `cursor`. +- Successful result metadata contains `childSessionID`, `model`, and `status`. + +- [ ] **Step 1: Replace direct-runtime mocks with child-session mocks** + +Remove the `runDelegate` mock from `test/cursor-tools.test.ts`. Add a fake client factory: + +```ts +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: {} }), + }, + }; +} +``` + +Update `withKey` and `noKey` dependencies to include the fake client and control callbacks. + +- [ ] **Step 2: Add failing child-session behavior tests** + +Cover permission denial, parent linking, directory/title, prompt model/parts/tool guard, control registration, returned text/metadata, prompt failure, and abort: + +```ts +it("creates and prompts a parent-linked Cursor child session", async () => { + const client = childClient(); + const setControls = vi.fn(); + const clearControls = vi.fn(); + const tools = buildCursorTools({ + resolveApiKey: () => "k", + defaultCwd: () => "/work", + client: client as any, + setDelegateControls: setControls, + clearDelegateControls: clearControls, + }); + + const out = await tools.cursor_delegate!.execute( + { prompt: "inspect files", model: "composer-2.5", mode: "plan", thinking: "high" } as any, + ctx(vi.fn().mockResolvedValue(undefined)), + ) as any; + + expect(client.session.create).toHaveBeenCalledWith({ + body: expect.objectContaining({ parentID: "s", title: expect.stringContaining("inspect files") }), + query: { directory: "/work" }, + }); + expect(client.session.prompt).toHaveBeenCalledWith({ + path: { id: "child-1" }, + query: { directory: "/work" }, + body: expect.objectContaining({ + model: { providerID: "cursor", modelID: "composer-2.5" }, + agent: "plan", + parts: [{ type: "text", text: "inspect files" }], + tools: { cursor_delegate: false }, + }), + }); + expect(setControls).toHaveBeenCalledWith("child-1", { + mode: "plan", + thinking: "high", + }); + expect(clearControls).toHaveBeenCalledWith("child-1"); + expect(out.output).toBe("child result"); + expect(out.metadata).toMatchObject({ childSessionID: "child-1", status: "finished" }); +}); +``` + +Add tests that denied permission makes zero SDK calls, prompt rejection returns `Delegation failed`, explicit `cwd` wins, and abort invokes `session.abort` for the child. + +- [ ] **Step 3: Run focused tests and verify failure** + +Run: `npm test -- --run test/cursor-tools.test.ts` + +Expected: FAIL because `cursor_delegate` still calls `runDelegate`. + +- [ ] **Step 4: Implement child-session orchestration** + +In `src/plugin/cursor-tools.ts`: + +1. Import `PluginInput` as a type and `PROVIDER_ID`. +2. Extend `CursorToolDeps` with the client and control callbacks. +3. Remove the `runDelegate` import and direct `DelegateResult` aggregation. +4. Add a bounded prompt-title helper that collapses whitespace and truncates the prompt preview. +5. Keep API-key resolution and `requestApproval` unchanged. +6. Create the child with: + +```ts +const created = await deps.client.session.create({ + body: { + parentID: context.sessionID, + title: delegateTitle(args.prompt), + }, + query: { directory }, +}); +``` + +7. Register controls before prompting: + +```ts +deps.setDelegateControls(childID, { + mode: args.mode ?? "agent", + ...(args.thinking ? { thinking: args.thinking } : {}), + ...(args.sandbox !== undefined ? { sandbox: args.sandbox } : {}), + ...(args.agentId ? { agentId: args.agentId } : {}), +}); +``` + +8. Prompt the child with `providerID: PROVIDER_ID`, the requested model, one text part, `agent: "plan"` only for plan mode, and `{ cursor_delegate: false }`. +9. Extract final output by concatenating returned parts whose type is `text`. +10. Return final text and metadata containing child session ID, model, and assistant token usage when available. +11. Register an abort listener that calls `client.session.abort({ path: { id: childID }, query: { directory } })`; remove it and clear controls in `finally`. +12. Convert create/prompt failures to `Delegation failed: ...` strings without masking cleanup failures. + +- [ ] **Step 5: Run focused tests** + +Run: `npm test -- --run test/cursor-tools.test.ts` + +Expected: PASS. + +--- + +### Task 3: Remove Obsolete Direct Delegate Runtime + +**Files:** +- Delete: `src/provider/delegate.ts` +- Delete: `test/delegate.test.ts` +- Modify: `test/plugin-tools.test.ts` + +**Interfaces:** +- No production code imports `runDelegate` after Task 2. +- Plugin integration tests use a fake opencode client and verify child-session delegation rather than mocking Cursor's direct delegate runtime. + +- [ ] **Step 1: Update plugin integration tests** + +Replace `runDelegate` mocks with a fake `input.client.session` implementation. Assert auth loader still supplies the key needed for the permission-gated tool and that the registered tool creates/prompts a child session. + +- [ ] **Step 2: Run the integration test before deletion** + +Run: `npm test -- --run test/plugin-tools.test.ts` + +Expected: FAIL only where assertions still expect `runDelegate`; use failures to remove those stale assertions. + +- [ ] **Step 3: Delete orphaned files and mocks** + +Delete `src/provider/delegate.ts` and `test/delegate.test.ts`. Remove all `runDelegate` imports, mocks, and assertions. Do not alter `cloud-agent.ts` or its tests. + +- [ ] **Step 4: Run all tests and typecheck** + +Run: `npm test && npm run typecheck` + +Expected: PASS with no `runDelegate` references in `src` or `test`. + +--- + +### Task 4: Update User-Facing Delegation Documentation + +**Files:** +- Modify: `README.md:107-112,251-264` +- Modify: `CHANGELOG.md` under the unreleased/current development entry if one exists; otherwise do not invent a release section + +**Interfaces:** +- Documentation explains that `cursor_delegate` creates a persistent child session and that the child UI shows live Cursor activity. +- Argument table remains accurate: `agentId`, `sandbox`, `mode`, `thinking`, `cwd`, `prompt`, and `model` are supported. + +- [ ] **Step 1: Update README delegation text** + +Change the local tool description from “return its result” to “run the task in a parent-linked child session, stream activity into the opencode subagent UI, and return the final result.” Add one sentence that the child session remains available after completion. + +- [ ] **Step 2: Add a focused changelog entry only if an existing unreleased section exists** + +If `CHANGELOG.md` has no unreleased/development section, leave it unchanged. Do not add release metadata for this feature. + +- [ ] **Step 3: Run final verification** + +Run: `npm test && npm run typecheck && npm run build` + +Expected: PASS. Inspect `git diff --check` and verify only the design, plan, implementation, tests, and directly related documentation changed. + +## Verification Matrix + +- Permission gate: denied calls create no child session. +- Parent linkage: child session has `parentID === context.sessionID`. +- Live rendering path: child uses provider `cursor`, so existing stream-map parts are persisted by opencode. +- Control forwarding: mode, thinking, sandbox, and explicit Cursor agent ID reach the provider only for the matching child session. +- Abort: child session abort endpoint is called once and listeners/maps are cleaned up. +- Persistence: no child deletion occurs after completion. +- Regression: cloud delegation and normal Cursor provider turns retain existing behavior. 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 () => { From 19a173a36ef65a11b20747b8eb460faff9f4b732 Mon Sep 17 00:00:00 2001 From: Justin Carper Date: Wed, 15 Jul 2026 14:04:08 -0500 Subject: [PATCH 2/2] chore: drop implementation plan from tracked docs --- .../2026-07-15-cursor-delegate-subagent-ui.md | 346 ------------------ 1 file changed, 346 deletions(-) delete mode 100644 docs/superpowers/plans/2026-07-15-cursor-delegate-subagent-ui.md diff --git a/docs/superpowers/plans/2026-07-15-cursor-delegate-subagent-ui.md b/docs/superpowers/plans/2026-07-15-cursor-delegate-subagent-ui.md deleted file mode 100644 index bbc1e7d..0000000 --- a/docs/superpowers/plans/2026-07-15-cursor-delegate-subagent-ui.md +++ /dev/null @@ -1,346 +0,0 @@ -# Cursor Delegate Subagent UI Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Route `cursor_delegate` through a parent-linked opencode child session so Cursor activity appears live and remains navigable in the opencode subagent UI. - -**Architecture:** Keep the existing auth and permission gate. Replace the direct `runDelegate` call with `client.session.create({ parentID })` and blocking `client.session.prompt(...)`. Use a short-lived child-session control map consumed by the existing `chat.params` hook to forward Cursor-only per-turn controls that the SDK prompt schema cannot carry directly. - -**Tech Stack:** TypeScript, `@opencode-ai/plugin`, `@opencode-ai/sdk`, `@cursor/sdk`, Vitest, opencode V3 provider stream pipeline. - -> **Status: Implemented.** All tasks below are complete. `cursor_delegate` routes through a parent-linked child session; per-turn `mode`/`thinking`/`sandbox`/`cwd`/`agentId` are forwarded via `chat.params`; the direct `runDelegate` runtime is removed; 298 tests, typecheck, and build pass. The unchecked step boxes are retained as the historical build record. - -## Global Constraints - -- `cursor_delegate` is the scoped feature; cloud delegation and Cursor internal subagents remain unchanged. -- Permission approval occurs before child-session creation or Cursor execution. -- Child sessions persist after completion. -- No private TUI APIs or synthetic event injection. -- No silent fallback to the old collapsed direct-SDK result. -- Preserve `prompt`, `model`, `mode`, `thinking`, `cwd`, `sandbox`, and `agentId` semantics. - ---- - -### Task 1: Add Delegate Control Bridge - -**Files:** -- Modify: `src/plugin/index.ts` -- Modify: `src/provider/language-model.ts:138-290` -- Modify: `test/plugin-tools.test.ts` -- Modify: `test/language-model.test.ts` - -**Interfaces:** -- Produces `CursorDelegateControls` with `mode`, optional `thinking`, optional `sandbox`, and optional `agentId`. -- Produces plugin-local `Map` registration and cleanup callbacks passed to `buildCursorTools`. -- Makes `chat.params` merge registered controls into `providerOptions.cursor` for the matching child session. - -- [ ] **Step 1: Add failing plugin-hook tests** - -Extend the existing `CursorPlugin chat.params hook` tests with a child-session control case: - -```ts -it("applies registered delegate controls only to the matching child session", async () => { - let childOptions: Record | undefined; - const client = fakeClient(); - const hooks = await plugin({ directory: "/work", client } as never); - 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; - return { data: { info: {}, parts: [{ type: "text", text: "done" }] } } as any; - }); - - await hooks.tool!.cursor_delegate!.execute( - { prompt: "p", model: "m", thinking: "high", sandbox: true, agentId: "cursor-agent" } as any, - ctx(vi.fn().mockResolvedValue(undefined)), - ); - - expect(childOptions).toMatchObject({ - mode: "agent", - thinking: "high", - sandbox: true, - agentId: "cursor-agent", - sessionID: "child", - }); -}); -``` - -Use the same registration path production code uses; do not introduce a test-only hook. - -- [ ] **Step 2: Run the focused test and verify failure** - -Run: `npm test -- --run test/plugin-tools.test.ts` - -Expected: FAIL because no delegate control registration path exists yet. - -- [ ] **Step 3: Implement the control map** - -In `src/plugin/index.ts`, add a plugin-local map and callbacks: - -```ts -export interface CursorDelegateControls { - mode: "agent" | "plan"; - thinking?: string; - sandbox?: boolean; - agentId?: string; -} - -const delegateControls = new Map(); - -const setDelegateControls = (sessionID: string, controls: CursorDelegateControls) => { - delegateControls.set(sessionID, controls); -}; - -const clearDelegateControls = (sessionID: string) => { - delegateControls.delete(sessionID); -}; -``` - -In `chat.params`, after the existing plan-agent mapping, merge the matching entry: - -```ts -const controls = delegateControls.get(input.sessionID); -if (controls) { - output.options = { - ...(output.options ?? {}), - mode: controls.mode, - ...(controls.thinking ? { thinking: controls.thinking } : {}), - ...(controls.sandbox !== undefined ? { sandbox: controls.sandbox } : {}), - ...(controls.agentId ? { agentId: controls.agentId } : {}), - }; -} -``` - -Pass `setDelegateControls` and `clearDelegateControls` into `buildCursorTools`. Keep controls session-scoped and remove them after the prompt settles. - -- [ ] **Step 4: Add per-turn sandbox resolution** - -In `src/provider/language-model.ts`, resolve sandbox from provider options before constructing `baseAcquire`: - -```ts -const sandbox = - typeof providerOptions?.["sandbox"] === "boolean" - ? providerOptions["sandbox"] - : this.config.sandbox; -``` - -Use `sandbox` in the existing acquire spread instead of `this.config.sandbox`. Keep all other acquire behavior unchanged. - -- [ ] **Step 5: Add provider regression coverage** - -Extend the existing language-model mock assertions to verify that `providerOptions.cursor.sandbox` is forwarded to `acquireAgent`, while an omitted per-turn value still uses the static provider setting. Keep existing explicit `agentId` coverage and add `thinking`/mode assertions only where the current test harness already observes `resolveControls` inputs. - -- [ ] **Step 6: Run focused tests** - -Run: `npm test -- --run test/plugin-tools.test.ts test/language-model.test.ts` - -Expected: PASS. - ---- - -### Task 2: Route `cursor_delegate` Through Child Session - -**Files:** -- Modify: `src/plugin/cursor-tools.ts` -- Modify: `test/cursor-tools.test.ts` -- Modify: `src/plugin/index.ts` only if dependency wiring from Task 1 needs adjustment - -**Interfaces:** -- `CursorToolDeps` consumes `client`, `setDelegateControls`, and `clearDelegateControls`. -- `cursor_delegate.execute` creates a child session using `context.sessionID` and prompts it with provider `cursor`. -- Successful result metadata contains `childSessionID`, `model`, and `status`. - -- [ ] **Step 1: Replace direct-runtime mocks with child-session mocks** - -Remove the `runDelegate` mock from `test/cursor-tools.test.ts`. Add a fake client factory: - -```ts -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: {} }), - }, - }; -} -``` - -Update `withKey` and `noKey` dependencies to include the fake client and control callbacks. - -- [ ] **Step 2: Add failing child-session behavior tests** - -Cover permission denial, parent linking, directory/title, prompt model/parts/tool guard, control registration, returned text/metadata, prompt failure, and abort: - -```ts -it("creates and prompts a parent-linked Cursor child session", async () => { - const client = childClient(); - const setControls = vi.fn(); - const clearControls = vi.fn(); - const tools = buildCursorTools({ - resolveApiKey: () => "k", - defaultCwd: () => "/work", - client: client as any, - setDelegateControls: setControls, - clearDelegateControls: clearControls, - }); - - const out = await tools.cursor_delegate!.execute( - { prompt: "inspect files", model: "composer-2.5", mode: "plan", thinking: "high" } as any, - ctx(vi.fn().mockResolvedValue(undefined)), - ) as any; - - expect(client.session.create).toHaveBeenCalledWith({ - body: expect.objectContaining({ parentID: "s", title: expect.stringContaining("inspect files") }), - query: { directory: "/work" }, - }); - expect(client.session.prompt).toHaveBeenCalledWith({ - path: { id: "child-1" }, - query: { directory: "/work" }, - body: expect.objectContaining({ - model: { providerID: "cursor", modelID: "composer-2.5" }, - agent: "plan", - parts: [{ type: "text", text: "inspect files" }], - tools: { cursor_delegate: false }, - }), - }); - expect(setControls).toHaveBeenCalledWith("child-1", { - mode: "plan", - thinking: "high", - }); - expect(clearControls).toHaveBeenCalledWith("child-1"); - expect(out.output).toBe("child result"); - expect(out.metadata).toMatchObject({ childSessionID: "child-1", status: "finished" }); -}); -``` - -Add tests that denied permission makes zero SDK calls, prompt rejection returns `Delegation failed`, explicit `cwd` wins, and abort invokes `session.abort` for the child. - -- [ ] **Step 3: Run focused tests and verify failure** - -Run: `npm test -- --run test/cursor-tools.test.ts` - -Expected: FAIL because `cursor_delegate` still calls `runDelegate`. - -- [ ] **Step 4: Implement child-session orchestration** - -In `src/plugin/cursor-tools.ts`: - -1. Import `PluginInput` as a type and `PROVIDER_ID`. -2. Extend `CursorToolDeps` with the client and control callbacks. -3. Remove the `runDelegate` import and direct `DelegateResult` aggregation. -4. Add a bounded prompt-title helper that collapses whitespace and truncates the prompt preview. -5. Keep API-key resolution and `requestApproval` unchanged. -6. Create the child with: - -```ts -const created = await deps.client.session.create({ - body: { - parentID: context.sessionID, - title: delegateTitle(args.prompt), - }, - query: { directory }, -}); -``` - -7. Register controls before prompting: - -```ts -deps.setDelegateControls(childID, { - mode: args.mode ?? "agent", - ...(args.thinking ? { thinking: args.thinking } : {}), - ...(args.sandbox !== undefined ? { sandbox: args.sandbox } : {}), - ...(args.agentId ? { agentId: args.agentId } : {}), -}); -``` - -8. Prompt the child with `providerID: PROVIDER_ID`, the requested model, one text part, `agent: "plan"` only for plan mode, and `{ cursor_delegate: false }`. -9. Extract final output by concatenating returned parts whose type is `text`. -10. Return final text and metadata containing child session ID, model, and assistant token usage when available. -11. Register an abort listener that calls `client.session.abort({ path: { id: childID }, query: { directory } })`; remove it and clear controls in `finally`. -12. Convert create/prompt failures to `Delegation failed: ...` strings without masking cleanup failures. - -- [ ] **Step 5: Run focused tests** - -Run: `npm test -- --run test/cursor-tools.test.ts` - -Expected: PASS. - ---- - -### Task 3: Remove Obsolete Direct Delegate Runtime - -**Files:** -- Delete: `src/provider/delegate.ts` -- Delete: `test/delegate.test.ts` -- Modify: `test/plugin-tools.test.ts` - -**Interfaces:** -- No production code imports `runDelegate` after Task 2. -- Plugin integration tests use a fake opencode client and verify child-session delegation rather than mocking Cursor's direct delegate runtime. - -- [ ] **Step 1: Update plugin integration tests** - -Replace `runDelegate` mocks with a fake `input.client.session` implementation. Assert auth loader still supplies the key needed for the permission-gated tool and that the registered tool creates/prompts a child session. - -- [ ] **Step 2: Run the integration test before deletion** - -Run: `npm test -- --run test/plugin-tools.test.ts` - -Expected: FAIL only where assertions still expect `runDelegate`; use failures to remove those stale assertions. - -- [ ] **Step 3: Delete orphaned files and mocks** - -Delete `src/provider/delegate.ts` and `test/delegate.test.ts`. Remove all `runDelegate` imports, mocks, and assertions. Do not alter `cloud-agent.ts` or its tests. - -- [ ] **Step 4: Run all tests and typecheck** - -Run: `npm test && npm run typecheck` - -Expected: PASS with no `runDelegate` references in `src` or `test`. - ---- - -### Task 4: Update User-Facing Delegation Documentation - -**Files:** -- Modify: `README.md:107-112,251-264` -- Modify: `CHANGELOG.md` under the unreleased/current development entry if one exists; otherwise do not invent a release section - -**Interfaces:** -- Documentation explains that `cursor_delegate` creates a persistent child session and that the child UI shows live Cursor activity. -- Argument table remains accurate: `agentId`, `sandbox`, `mode`, `thinking`, `cwd`, `prompt`, and `model` are supported. - -- [ ] **Step 1: Update README delegation text** - -Change the local tool description from “return its result” to “run the task in a parent-linked child session, stream activity into the opencode subagent UI, and return the final result.” Add one sentence that the child session remains available after completion. - -- [ ] **Step 2: Add a focused changelog entry only if an existing unreleased section exists** - -If `CHANGELOG.md` has no unreleased/development section, leave it unchanged. Do not add release metadata for this feature. - -- [ ] **Step 3: Run final verification** - -Run: `npm test && npm run typecheck && npm run build` - -Expected: PASS. Inspect `git diff --check` and verify only the design, plan, implementation, tests, and directly related documentation changed. - -## Verification Matrix - -- Permission gate: denied calls create no child session. -- Parent linkage: child session has `parentID === context.sessionID`. -- Live rendering path: child uses provider `cursor`, so existing stream-map parts are persisted by opencode. -- Control forwarding: mode, thinking, sandbox, and explicit Cursor agent ID reach the provider only for the matching child session. -- Abort: child session abort endpoint is called once and listeners/maps are cleaned up. -- Persistence: no child deletion occurs after completion. -- Regression: cloud delegation and normal Cursor provider turns retain existing behavior.