diff --git a/.agents/skills/interactive-testing/scripts/poracode-cdp.mjs b/.agents/skills/interactive-testing/scripts/poracode-cdp.mjs index 5409193ab..e890c5cfc 100644 --- a/.agents/skills/interactive-testing/scripts/poracode-cdp.mjs +++ b/.agents/skills/interactive-testing/scripts/poracode-cdp.mjs @@ -276,12 +276,15 @@ async function waitForTarget(timeoutMs) { // A window's kind never changes, so remember each probed target across poll // iterations instead of re-opening a WebSocket to it every second. const probedKindsByTargetId = new Map(); + let lastTargets = null; while (Date.now() - start < timeoutMs) { try { const res = await fetch(`http://127.0.0.1:${port}/json/list`); if (res.ok) { const targets = await res.json(); - const candidates = targets.filter((t) => t.type === "page" && t.url === appUrl); + lastTargets = targets; + const pageTargets = targets.filter((t) => t.type === "page"); + const candidates = pageTargets.filter((t) => t.url === appUrl); if (!windowKind && candidates[0]) return candidates[0]; for (const target of candidates) { let kind = probedKindsByTargetId.get(target.id); @@ -296,12 +299,37 @@ async function waitForTarget(timeoutMs) { } if (kind === windowKind) return target; } + // CDP is up and serving targets, but none match the expected URL. + // Fail fast instead of polling — the URL won't change. + if (pageTargets.length > 0) { + const available = pageTargets.map((t) => t.url).join(", "); + throw new Error( + `no app CDP target matching ${appUrl} on port ${port}. ` + + `Available page targets: ${available}. ` + + `Set PORACODE_APP_URL to the correct URL.`, + ); + } } - } catch { + } catch (err) { + // If we threw the fast-fail error above, propagate it. + if (err?.message?.includes("no app CDP target matching")) throw err; // CDP endpoint not up yet — keep polling. } await new Promise((r) => setTimeout(r, 1000)); } + // Timeout reached. If CDP was serving targets but none matched, give a + // diagnostic instead of a bare null. + if (lastTargets) { + const pageTargets = lastTargets.filter((t) => t.type === "page"); + if (pageTargets.length > 0) { + const available = pageTargets.map((t) => t.url).join(", "); + throw new Error( + `no app CDP target matching ${appUrl} on port ${port}. ` + + `Available page targets: ${available}. ` + + `Set PORACODE_APP_URL to the correct URL.`, + ); + } + } return null; } diff --git a/.agents/skills/interactive-testing/scripts/poracode-integration-smoke.mjs b/.agents/skills/interactive-testing/scripts/poracode-integration-smoke.mjs index 807625ef8..e8bb4255c 100644 --- a/.agents/skills/interactive-testing/scripts/poracode-integration-smoke.mjs +++ b/.agents/skills/interactive-testing/scripts/poracode-integration-smoke.mjs @@ -658,12 +658,15 @@ async function skillsSectionDeepDive(client) { async function mcpServersSectionDeepDive(client, mcpFixture) { const mcpState = await evaluate( client, - `(() => ({ - builtInsVisible: document.body.innerText.includes("Built-in MCP servers"), - builtInToolCount: document.body.innerText.includes("44 tools"), - addButton: Boolean([...document.querySelectorAll("button")].find((button) => button.textContent?.trim() === "Add MCP server")), - browserSwitch: Boolean(document.querySelector('[role="switch"][aria-label="Disable Browser"]')), - }))()`, + `(() => { + const browserRow = document.querySelector('[data-built-in-mcp-server="browser"]'); + return { + builtInsVisible: document.body.innerText.includes("Built-in MCP servers"), + builtInToolCount: /\\b\\d+ tools?\\b/.test(browserRow?.textContent ?? ""), + addButton: Boolean([...document.querySelectorAll("button")].find((button) => button.textContent?.trim() === "Add MCP server")), + browserSwitch: Boolean(document.querySelector('[role="switch"][aria-label="Disable Browser"]')), + }; + })()`, ); assert(mcpState.builtInsVisible, "MCP settings did not render built-in servers"); assert(mcpState.builtInToolCount, "MCP settings did not render built-in tool counts"); @@ -1512,21 +1515,37 @@ async function installWindowErrorCollector(client) { async function waitForTarget() { const started = Date.now(); + let cdpRespondedWithPages = false; + let lastPageUrls = []; while (Date.now() - started < timeoutMs) { try { const response = await fetch(`http://127.0.0.1:${port}/json/list`); if (response.ok) { const targets = await response.json(); - const target = targets.find( - (candidate) => candidate.type === "page" && candidate.url === appUrl, - ); + const pageTargets = targets.filter((t) => t.type === "page"); + const target = pageTargets.find((candidate) => candidate.url === appUrl); if (target) return target; + // CDP is up with page targets but none match — the renderer loaded + // on a different URL than expected. Fail fast instead of polling. + if (pageTargets.length > 0) { + cdpRespondedWithPages = true; + lastPageUrls = pageTargets.map((t) => t.url); + } } } catch { // Electron is still starting. } + // Once CDP serves page targets that don't match, the URL won't change. + if (cdpRespondedWithPages) break; await new Promise((resolveWait) => setTimeout(resolveWait, 500)); } + if (cdpRespondedWithPages) { + throw new Error( + `no Poracode CDP target matching ${appUrl} on port ${port}. ` + + `Available page targets: ${lastPageUrls.join(", ")}. ` + + `Check PORACODE_APP_URL / port allocation.`, + ); + } throw new Error(`no Poracode CDP target at ${appUrl} on port ${port}`); } diff --git a/.gitignore b/.gitignore index d05d5524d..5931c4a0d 100644 --- a/.gitignore +++ b/.gitignore @@ -29,6 +29,7 @@ resources/wsl-helpers/watcher.node resources/wsl-helpers/bridge.mjs resources/wsl-helpers/mcp-probe.mjs resources/wsl-helpers/mcp-filter.mjs +resources/wsl-helpers/cursor-sdk-worker.mjs resources/agent-plugins/ resources/mobile-ssh-runtime/ .envrc diff --git a/package.json b/package.json index 5936d20ee..caa61007c 100644 --- a/package.json +++ b/package.json @@ -25,7 +25,7 @@ "dev:renderer": "node scripts/free-port.mjs && vite", "dev:mobile": "node scripts/free-port.mjs 3100 && cross-env PORACODE_BUILD_TARGET=mobile vite", "dev:electron": "tsdown --watch", - "dev:app": "pnpm run setup:native && wait-on dist/main/main.cjs dist/main/mcpProbeWorker.mjs && pnpm run prepare:package-assets && node scripts/wait-dev-server.mjs && node scripts/dev-launch.mjs", + "dev:app": "pnpm run setup:native && wait-on dist/main/main.cjs dist/main/mcpProbeWorker.mjs dist/main/cursorSdkWorker.mjs && pnpm run prepare:package-assets && node scripts/wait-dev-server.mjs && node scripts/dev-launch.mjs", "build": "pnpm run build:renderer && pnpm run build:electron", "build:renderer": "vite build", "build:mobile": "pnpm run prepare:mobile:ssh && cross-env PORACODE_BUILD_TARGET=mobile vite build && node scripts/finalize-mobile-build.mjs", diff --git a/scripts/build-desktop-artifact.mjs b/scripts/build-desktop-artifact.mjs index b842b5ffb..e1ce27125 100644 --- a/scripts/build-desktop-artifact.mjs +++ b/scripts/build-desktop-artifact.mjs @@ -562,6 +562,7 @@ asarUnpack: - node_modules/node-pty/**/* - node_modules/better-sqlite3/**/* - dist/main/claudeSdkProbeWorker.mjs + - dist/main/cursorSdkWorker.mjs - node_modules/@anthropic-ai/claude-agent-sdk/**/* afterPack: build/after-pack.cjs diff --git a/scripts/prepare-wsl-helpers.mjs b/scripts/prepare-wsl-helpers.mjs index 97369e564..79e6089a7 100644 --- a/scripts/prepare-wsl-helpers.mjs +++ b/scripts/prepare-wsl-helpers.mjs @@ -1,7 +1,7 @@ /** * Stages every Node helper that we run *inside* a WSL distro into * `resources/wsl-helpers/` so electron-builder can bundle them as - * extraResources. Two artefacts ride this pipeline: + * extraResources. Five artefacts ride this pipeline: * * 1. `watcher.node` — @parcel/watcher Linux x64 native binding, * downloaded via `npm pack`. Loaded by `bridge.mjs` for watch @@ -11,6 +11,8 @@ * 3. `mcp-probe.mjs` — self-contained MCP client used to verify workspace * servers in the same distro where providers run. * 4. `mcp-filter.mjs` — same-environment MCP proxy that removes disabled tools. + * 5. `cursor-sdk-worker.mjs` — isolated transport shell that dynamically + * imports a Cursor SDK installed inside the target distro. * * Idempotency: presence + non-zero size on `watcher.node` skips the * `npm pack` download. `bridge.mjs` is always copied — the copy is <1ms @@ -45,6 +47,7 @@ stageWatcherBinary(); stageHookBridge(); stageMcpProbe(); stageMcpFilter(); +stageCursorSdkWorker(); function stageWatcherBinary() { const dest = join(destDir, "watcher.node"); @@ -117,12 +120,23 @@ function stageMcpFilter() { console.log(`[prepare-wsl-helpers] mcpToolFilterWorker.mjs -> ${dest}`); } -function assertSelfContainedWorker(path) { +function stageCursorSdkWorker() { + const src = join(repoRoot, "dist", "main", "cursorSdkWorker.mjs"); + if (!existsSync(src)) { + throw new Error(`Cursor SDK worker missing; run build:electron first: ${src}`); + } + assertSelfContainedWorker(src, "Cursor SDK worker"); + const dest = join(destDir, "cursor-sdk-worker.mjs"); + copyFileSync(src, dest); + console.log(`[prepare-wsl-helpers] cursorSdkWorker.mjs -> ${dest}`); +} + +function assertSelfContainedWorker(path, label = "MCP probe worker") { const source = readFileSync(path, "utf8"); const imports = source.matchAll(/^import(?:[\s\S]*?\sfrom\s*)?["']([^"']+)["'];?$/gm); const builtins = new Set([...builtinModules, ...builtinModules.map((name) => `node:${name}`)]); const external = [...imports].map((match) => match[1]).filter((name) => !builtins.has(name)); if (external.length > 0) { - throw new Error(`MCP probe worker is not self-contained: ${[...new Set(external)].join(", ")}`); + throw new Error(`${label} is not self-contained: ${[...new Set(external)].join(", ")}`); } } diff --git a/scripts/refresh-node-checksums.mjs b/scripts/refresh-node-checksums.mjs index 9a76871fc..367c5d161 100644 --- a/scripts/refresh-node-checksums.mjs +++ b/scripts/refresh-node-checksums.mjs @@ -6,7 +6,7 @@ * * Run after bumping `PORACODE_PINNED_NODE_VERSION` in that file: * - * pnpm tsx scripts/refresh-node-checksums.mjs + * node scripts/refresh-node-checksums.mjs * * Covers every target poracode ships a managed-runtime install for: * - linux-x64 / linux-arm64 (.tar.xz) — WSL + native Linux diff --git a/src/main/ssh/SshConnectionManager.test.ts b/src/main/ssh/SshConnectionManager.test.ts index 84f847b0f..49907a9d1 100644 --- a/src/main/ssh/SshConnectionManager.test.ts +++ b/src/main/ssh/SshConnectionManager.test.ts @@ -95,6 +95,7 @@ describe("SSH runtime bundle", () => { "server.cjs", "supervisor.cjs", "claudeSdkProbeWorker.mjs", + "cursorSdkWorker.mjs", "transcriptReader-generated.cjs", ]) { writeFileSync(join(mainBundleDir, file), file, "utf8"); @@ -116,6 +117,7 @@ describe("SSH runtime bundle", () => { const archiveName = basename(bundle.archivePath); const entries = execFileSync(tar, ["-tzf", archiveName], { cwd: archiveDir }).toString("utf8"); expect(entries).toContain("transcriptReader-generated.cjs"); + expect(entries).toContain("cursorSdkWorker.mjs"); const packageEntry = execFileSync(tar, ["-xOf", archiveName, "./package.json"], { cwd: archiveDir, }).toString("utf8"); diff --git a/src/main/ssh/runtimeBundle.ts b/src/main/ssh/runtimeBundle.ts index dcbca0c1c..4b791342f 100644 --- a/src/main/ssh/runtimeBundle.ts +++ b/src/main/ssh/runtimeBundle.ts @@ -180,7 +180,12 @@ export function ensureSshRuntimeBundle(options: SshRuntimeBundleOptions): SshRun } } - const requiredFiles = ["server.cjs", "supervisor.cjs", "claudeSdkProbeWorker.mjs"]; + const requiredFiles = [ + "server.cjs", + "supervisor.cjs", + "claudeSdkProbeWorker.mjs", + "cursorSdkWorker.mjs", + ]; for (const file of requiredFiles) { const source = join(options.mainBundleDir, file); if (!existsSync(source)) { diff --git a/src/mobile/ComposerActionDocks.tsx b/src/mobile/ComposerActionDocks.tsx index af84d387a..4184d43ec 100644 --- a/src/mobile/ComposerActionDocks.tsx +++ b/src/mobile/ComposerActionDocks.tsx @@ -1,4 +1,5 @@ import type { AgentStatus, Project, Thread } from "@/shared/contracts"; +import { agentStatusForPresentation } from "@/shared/agentSelection"; import { changeThreadConfig, clearThreadPendingSteer, @@ -36,21 +37,29 @@ export function ComposerActionDocks(props: { readonly onOpenPlanFile?: ((path: string) => void) | undefined; }) { const { thread, agentStatus, project } = props; + const presentationMode = + thread.presentationMode ?? agentStatus?.capabilities.presentationMode ?? "terminal"; + const effectiveAgentStatus = agentStatus + ? agentStatusForPresentation(agentStatus, presentationMode, thread.sessionRef) + : undefined; const request = useAppStore((state) => state.runtimeRequestsByThread[thread.id]?.[0]); const pendingSteer = useDelayedPendingSteer( useAppStore((state) => state.pendingSteerByThreadId[thread.id]), ); const { authRequired } = resolveThreadAuthState({ - authState: agentStatus?.authState, + authState: effectiveAgentStatus?.authState, errorDockStates: props.dockState.errorDockStates, }); - const showAuthDock = authRequired && agentStatus !== undefined; + const showAuthDock = authRequired && effectiveAgentStatus !== undefined; if (!showAuthDock && !pendingSteer && !request) return null; return (
{showAuthDock ? ( - + ) : null} {pendingSteer ? ( resolveThreadServerRequest(thread.id, input)} onPlanApproved={(optionId) => diff --git a/src/mobile/ComposerCompactSummary.tsx b/src/mobile/ComposerCompactSummary.tsx index ab584e4e3..583e1a681 100644 --- a/src/mobile/ComposerCompactSummary.tsx +++ b/src/mobile/ComposerCompactSummary.tsx @@ -1,6 +1,6 @@ import { useEffect, useRef } from "react"; import type { AgentStatus, Thread } from "@/shared/contracts"; -import { capabilitiesForPresentation } from "@/shared/agentSelection"; +import { agentStatusForPresentation } from "@/shared/agentSelection"; import { ProviderIcon } from "@/renderer/components/providers/ProviderIcon"; import { getComposerControls } from "@/renderer/components/providers/providerComposer"; import { @@ -26,22 +26,23 @@ export function ComposerCompactSummary(props: { const ref = useRef(null); const presentationMode = thread.presentationMode ?? agentStatus?.capabilities.presentationMode ?? "terminal"; - const presentationCapabilities = agentStatus - ? capabilitiesForPresentation(agentStatus.capabilities, presentationMode) + const effectiveAgentStatus = agentStatus + ? agentStatusForPresentation(agentStatus, presentationMode, thread.sessionRef) : undefined; + const presentationCapabilities = effectiveAgentStatus?.capabilities; const modelLabel = presentationCapabilities?.models.find((model) => model.id === thread.config.model)?.label ?? thread.config.model; const effortLabel = thread.config.effort ? formatEffortLabel(thread.config.effort) : undefined; let controls: ComposerControl[] = []; - if (agentStatus) { + if (effectiveAgentStatus) { if (presentationMode === "gui") { - controls = buildControls(thread, agentStatus, undefined, () => undefined); + controls = buildControls(thread, effectiveAgentStatus, undefined, () => undefined); } else { const buildProviderControls = getComposerControls(thread.agentKind); if (buildProviderControls) { controls = buildProviderControls({ - capabilities: capabilitiesForPresentation(agentStatus.capabilities, presentationMode), + capabilities: effectiveAgentStatus.capabilities, config: thread.config, isDisabled: true, onConfigChange: () => undefined, @@ -87,16 +88,16 @@ export function ComposerCompactSummary(props: { }; }, [agentStatus, effortLabel, fastEnabled, modeKey, modelLabel, permissionKey]); - if (!agentStatus) return null; + if (!effectiveAgentStatus) return null; return ( + +
+
+ {runtimeCards.map((runtime) => { + const controls = [ + runtime.capabilities?.modelContextSizes && + Object.keys(runtime.capabilities.modelContextSizes).length > 0 + ? t`Context` + : undefined, + runtime.capabilities?.efforts.length ? t`Reasoning` : undefined, + runtime.capabilities?.fastModels?.length ? t`Fast` : undefined, + runtime.capabilities?.thinkingModels?.length ? t`Thinking` : undefined, + ].filter((value): value is string => value !== undefined); + const modes = runtime.capabilities?.modes.map((mode) => + mode === "agent" ? t`Work` : mode === "plan" ? t`Plan` : t`Autopilot`, + ); + return ( +
+
+ {runtime.label} + + {structuredRuntime === runtime.id ? t`Default` : null} + +
+

+ {runtime.installed ? t`Installed` : t`Not installed`} + {runtime.installed && runtime.authState === "missing" + ? runtime.id === "sdk" + ? ` · ${t`API key required`}` + : ` · ${t`Sign in required`}` + : null} +

+ {runtime.capabilities ? ( + <> +

+ {t`${runtime.capabilities.models.length} models`} + {modes?.length ? ` · ${t`Modes`}: ${modes.join(", ")}` : null} +

+ {controls.length ? ( +

+ {t`Model controls`}: {controls.join(", ")} +

+ ) : null} + + ) : null} +
+ ); + })} +
+ + + ACP uses the Cursor Agent login. SDK uses @cursor/sdk with CURSOR_API_KEY. + + } + className="py-1.5" + > + setSdkPackagePath(event.currentTarget.value)} + /> + + + ) : null} +
+
+ ); +} diff --git a/src/renderer/views/SettingsOverlay/parts/NativeAgentActionControl.tsx b/src/renderer/views/SettingsOverlay/parts/NativeAgentActionControl.tsx new file mode 100644 index 000000000..9d5516965 --- /dev/null +++ b/src/renderer/views/SettingsOverlay/parts/NativeAgentActionControl.tsx @@ -0,0 +1,75 @@ +import type { ComponentType } from "react"; +import { Button, Dropdown, Label } from "@heroui/react"; +import { ChevronDown } from "lucide-react"; +import { PixelLoader } from "@/renderer/components/common"; + +export interface NativeAgentCardAction { + id: string; + label: string; +} + +/** + * Shared install/update control for native provider cards: a single action + * renders as one button, several render as a dropdown of the same button. All + * labels are resolved by the caller so the control stays provider-agnostic. + */ +export function NativeAgentActionControl(props: { + actions: readonly Action[]; + icon: ComponentType<{ className?: string }>; + /** Idle label of the dropdown trigger (several actions). */ + label: string; + /** Idle label when exactly one action is available. */ + soloLabel: string; + pendingLabel: string; + /** `aria-label` of the dropdown menu. */ + menuLabel: string; + isPending: boolean; + onRun: (action: Action | undefined) => void; +}) { + const Icon = props.icon; + if (props.actions.length === 0) return null; + if (props.actions.length === 1) { + const action = props.actions[0]; + return ( + + ); + } + const actionsById = new Map(props.actions.map((action) => [action.id, action])); + return ( + + + + props.onRun(actionsById.get(String(key)))} + > + {props.actions.map((action) => ( + + + + ))} + + + + ); +} diff --git a/src/renderer/views/SettingsOverlay/parts/agentRegistryNative.ts b/src/renderer/views/SettingsOverlay/parts/agentRegistryNative.ts index 38d2bda34..e81567e6d 100644 --- a/src/renderer/views/SettingsOverlay/parts/agentRegistryNative.ts +++ b/src/renderer/views/SettingsOverlay/parts/agentRegistryNative.ts @@ -4,7 +4,10 @@ import type { MessageDescriptor } from "@lingui/core"; import type { AgentKind, AgentProviderMetadata, AgentStatus, Project } from "@/shared/contracts"; import { isMac, isWindows, readBridge } from "@/renderer/bridge"; import { ClaudeAgentSettingsPanel } from "./ClaudeProfileSettings"; +import { CursorProviderSettings } from "./CursorProviderSettings"; import { OpenCodeProviderSettings } from "./OpenCodeProviderSettings"; +import { cursorAgentInstallCommand, cursorRuntimeSlots } from "./cursorRuntimeInstall"; +import type { NativeAgentRuntimeSlots } from "./nativeAgentRuntimes"; /** * Props handed to a provider's `settingsPanel`. Panels may consume any @@ -28,6 +31,13 @@ export interface NativeAgentRegistryEntry { description: MessageDescriptor; installCommand: (project: Project) => string; docsUrl: string; + /** + * Providers whose tile hosts several independently installed runtimes declare + * them here. The registry card then renders install targets, installed tags, + * versions and update actions generically from these slots plus the detected + * `AgentStatus.runtimeVariants`, instead of a single all-or-nothing install. + */ + runtimeSlots?: NativeAgentRuntimeSlots; /** * ACP registry ids that belong to this built-in provider family. Registry * cards use these aliases to resolve native installs and hide redundant ACP @@ -256,15 +266,11 @@ export const NATIVE_AGENT_REGISTRY_ENTRIES: NativeAgentRegistryEntry[] = [ { id: "cursor", acpRegistryAliases: [{ id: "cursor", nativeSupport: true }], - description: msg`First-class Cursor Agent integration using Poracode's native runtime.`, + description: msg`First-class Cursor integration using ACP or the public SDK runtime.`, docsUrl: "https://cursor.com/docs/cli/installation", - installCommand: (project) => - posixOrWindows( - project, - "if command -v curl >/dev/null 2>&1; then curl https://cursor.com/install -fsS | bash; " + - "else printf 'curl is required to install Cursor. Install curl, then refresh detected agents.\\n'; fi", - "if (Get-Command irm -ErrorAction SilentlyContinue) { irm 'https://cursor.com/install?win32=true' | iex } else { Write-Host 'No supported installer found. Install PowerShell Invoke-RestMethod first, then refresh detected agents.' }", - ), + installCommand: cursorAgentInstallCommand, + runtimeSlots: cursorRuntimeSlots, + settingsPanel: CursorProviderSettings, }, { id: "gemini", diff --git a/src/renderer/views/SettingsOverlay/parts/cursorRuntimeInstall.test.ts b/src/renderer/views/SettingsOverlay/parts/cursorRuntimeInstall.test.ts new file mode 100644 index 000000000..851391d87 --- /dev/null +++ b/src/renderer/views/SettingsOverlay/parts/cursorRuntimeInstall.test.ts @@ -0,0 +1,139 @@ +import { describe, expect, it } from "vitest"; +import type { AgentStatus, Project } from "@/shared/contracts"; +import { + canUpdateCursorSdk, + cursorRuntimeInstallCommand, + cursorRuntimeInstallState, + cursorRuntimeSlots, + cursorSdkUpdateCommand, +} from "./cursorRuntimeInstall"; +import { availableRuntimeInstallOptions } from "./nativeAgentRuntimes"; + +function offeredRuntimes(agentStatus: AgentStatus | undefined): string[] { + return availableRuntimeInstallOptions(cursorRuntimeSlots, agentStatus).map((option) => option.id); +} + +function status( + acp: boolean, + sdk: boolean, + sdkOptions?: { version?: string; installationSource?: string }, +): AgentStatus { + const capabilities = { + models: [], + efforts: [], + modelEfforts: {}, + modes: ["agent" as const], + approvalPolicies: [], + sandboxModes: [], + supportsResume: true, + supportsDirectInput: false, + liveInputMode: "server" as const, + presentationMode: "gui" as const, + settingDefs: [], + }; + return { + kind: "cursor", + label: "Cursor", + installed: acp || sdk, + authState: "authenticated", + capabilities, + runtimeVariants: { + acp: { + presentationMode: "gui", + installed: acp, + authState: "authenticated", + authUsesProviderLogin: true, + capabilities, + }, + sdk: { + presentationMode: "gui", + installed: sdk, + ...(sdkOptions?.version ? { version: sdkOptions.version } : {}), + ...(sdkOptions?.installationSource + ? { installationSource: sdkOptions.installationSource } + : {}), + authState: "authenticated", + authUsesProviderLogin: false, + capabilities, + }, + }, + }; +} + +const posixProject: Project = { + id: "project", + name: "Project", + location: { kind: "posix", path: "/repo" }, + createdAt: "2026-07-27T00:00:00.000Z", +}; + +describe("Cursor runtime installation", () => { + it("offers ACP, SDK, or both when neither runtime is installed", () => { + expect(offeredRuntimes(undefined)).toEqual(["acp", "sdk", "both"]); + }); + + it("offers only the missing runtime when one is already installed", () => { + expect(offeredRuntimes(status(true, false))).toEqual(["sdk"]); + expect(offeredRuntimes(status(false, true))).toEqual(["acp"]); + expect(offeredRuntimes(status(true, true))).toEqual([]); + }); + + it("treats a legacy installed Cursor status as an ACP install", () => { + const legacy = { ...status(true, false), runtimeVariants: undefined }; + expect(cursorRuntimeInstallState(legacy)).toMatchObject({ + acpInstalled: true, + sdkInstalled: false, + }); + }); + + it("installs the public SDK in the global package root discovered by the worker", () => { + expect(cursorRuntimeInstallCommand("sdk", posixProject)).toContain( + "npm install -g '@cursor/sdk@^1.0.24'", + ); + const both = cursorRuntimeInstallCommand("both", posixProject); + expect(both).toContain("https://cursor.com/install"); + expect(both).toContain("@cursor/sdk@^1.0.24"); + }); + + it("reports independent ACP and SDK versions", () => { + const installed = { + ...status(true, true, { version: "1.0.31", installationSource: "global-npm" }), + version: "2026.07.23", + runtimeVariants: { + ...status(true, true).runtimeVariants, + acp: { + ...status(true, true).runtimeVariants?.acp, + version: "2026.07.23", + }, + sdk: { + ...status(true, true).runtimeVariants?.sdk, + version: "1.0.31", + installationSource: "global-npm", + }, + }, + } as AgentStatus; + + expect(cursorRuntimeInstallState(installed)).toMatchObject({ + acpVersion: "2026.07.23", + sdkVersion: "1.0.31", + sdkInstallationSource: "global-npm", + }); + }); + + it("updates managed npm and pnpm SDK installs within the supported 1.x range", () => { + const npmStatus = status(true, true, { installationSource: "global-npm" }); + const pnpmStatus = status(true, true, { installationSource: "global-pnpm" }); + const projectStatus = status(true, true, { installationSource: "project" }); + + expect(canUpdateCursorSdk(npmStatus)).toBe(true); + expect(cursorSdkUpdateCommand(npmStatus, posixProject)).toContain( + "npm install -g '@cursor/sdk@^1.0.24'", + ); + expect(canUpdateCursorSdk(pnpmStatus)).toBe(true); + expect(cursorSdkUpdateCommand(pnpmStatus, posixProject)).toContain( + "pnpm add -g '@cursor/sdk@^1.0.24'", + ); + expect(canUpdateCursorSdk(projectStatus)).toBe(false); + expect(cursorSdkUpdateCommand(projectStatus, posixProject)).toBeUndefined(); + }); +}); diff --git a/src/renderer/views/SettingsOverlay/parts/cursorRuntimeInstall.ts b/src/renderer/views/SettingsOverlay/parts/cursorRuntimeInstall.ts new file mode 100644 index 000000000..19ce8c5ba --- /dev/null +++ b/src/renderer/views/SettingsOverlay/parts/cursorRuntimeInstall.ts @@ -0,0 +1,198 @@ +import { msg } from "@lingui/core/macro"; +import type { AgentStatus, Project } from "@/shared/contracts"; +import type { NativeAgentRuntimeSlots } from "./nativeAgentRuntimes"; + +export type CursorInstallRuntime = "acp" | "sdk" | "both"; + +export interface CursorRuntimeInstallState { + acpInstalled: boolean; + sdkInstalled: boolean; + acpVersion?: string; + sdkVersion?: string; + sdkInstallationSource?: string; +} + +export function cursorRuntimeInstallState( + status: AgentStatus | undefined, +): CursorRuntimeInstallState { + const variants = status?.runtimeVariants; + const acpVersion = + variants?.acp?.version ?? (variants?.acp?.installed ? status?.version : undefined); + const sdkVersion = + variants?.sdk?.version ?? (!variants?.acp?.installed ? status?.version : undefined); + return { + // Older cached Cursor statuses predate runtimeVariants and always represent + // the installed Cursor Agent, so preserve that as the ACP fallback. + acpInstalled: variants?.acp?.installed ?? status?.installed ?? false, + sdkInstalled: variants?.sdk?.installed ?? false, + ...(acpVersion ? { acpVersion } : {}), + ...(sdkVersion ? { sdkVersion } : {}), + ...(variants?.sdk?.installationSource + ? { sdkInstallationSource: variants.sdk.installationSource } + : {}), + }; +} + +/** + * Cursor install/update commands branch on the *project* location rather than + * the detected host platform (the registry's `posixOrWindows` helper): a remote + * client's advertised platform can fall back to the client UA before pairing + * completes, while `location.kind` always describes the shell the command will + * actually run in. + */ +function isWindowsProject(project: Project): boolean { + return project.location.kind === "windows"; +} + +/** `if exists then else explain` in the project's shell dialect. */ +function guardedCommand( + project: Project, + tool: string, + body: string, + missingMessage: string, +): string { + return isWindowsProject(project) + ? `if (Get-Command ${tool} -ErrorAction SilentlyContinue) { ${body} } else { Write-Host '${missingMessage}' }` + : `if command -v ${tool} >/dev/null 2>&1; then ${body}; else printf '${missingMessage}\\n'; fi`; +} + +/** Supported `@cursor/sdk` range — see sdkLoader's compatibility check. */ +const CURSOR_SDK_PACKAGE_SPEC = "'@cursor/sdk@^1.0.24'"; +const MISSING_CURL_MESSAGE = + "curl is required to install Cursor. Install curl, then refresh detected agents."; +const MISSING_WINDOWS_INSTALLER_MESSAGE = + "No supported installer found. Install PowerShell Invoke-RestMethod first, then refresh detected agents."; +const MISSING_NPM_MESSAGE = + "npm is required to install the Cursor SDK. Install Node.js/npm first, then refresh detected agents."; +const MISSING_PNPM_MESSAGE = "pnpm is required to update this Cursor SDK installation."; + +export function cursorAgentInstallCommand(project: Project): string { + return isWindowsProject(project) + ? guardedCommand( + project, + "irm", + "irm 'https://cursor.com/install?win32=true' | iex", + MISSING_WINDOWS_INSTALLER_MESSAGE, + ) + : guardedCommand( + project, + "curl", + "curl https://cursor.com/install -fsS | bash", + MISSING_CURL_MESSAGE, + ); +} + +export function cursorSdkInstallCommand(project: Project): string { + return guardedCommand( + project, + "npm", + `npm install -g ${CURSOR_SDK_PACKAGE_SPEC}`, + MISSING_NPM_MESSAGE, + ); +} + +export function cursorSdkUpdateCommand(status: AgentStatus, project: Project): string | undefined { + const source = cursorRuntimeInstallState(status).sdkInstallationSource; + if (source === "global-pnpm") { + return guardedCommand( + project, + "pnpm", + `pnpm add -g ${CURSOR_SDK_PACKAGE_SPEC}`, + MISSING_PNPM_MESSAGE, + ); + } + if (source !== "global-npm") return undefined; + return cursorSdkInstallCommand(project); +} + +export function canUpdateCursorSdk(status: AgentStatus): boolean { + const source = cursorRuntimeInstallState(status).sdkInstallationSource; + return source === "global-npm" || source === "global-pnpm"; +} + +export function cursorRuntimeInstallCommand( + runtime: CursorInstallRuntime, + project: Project, +): string { + if (runtime === "acp") return cursorAgentInstallCommand(project); + if (runtime === "sdk") return cursorSdkInstallCommand(project); + + const acp = cursorAgentInstallCommand(project); + const sdk = cursorSdkInstallCommand(project); + return isWindowsProject(project) ? `${acp}; if ($?) { ${sdk} }` : `( ${acp} ) && ( ${sdk} )`; +} + +/** Cursor's install-source vocabulary; only Cursor knows what these mean. */ +function cursorSdkSourceLabel(source: string) { + if (source === "global-npm") return msg`global npm`; + if (source === "global-pnpm") return msg`global pnpm`; + if (source === "project") return msg`project managed`; + if (source === "configured") return msg`custom path`; + if (source === "node-path") return "NODE_PATH"; + if (source === "global-explicit" || source === "global-inferred") return msg`global`; + return undefined; +} + +/** + * Cursor ships two independently installed runtimes behind one provider tile: + * the Cursor Agent CLI (ACP) and the public `@cursor/sdk` package. The shared + * registry card renders both from this declaration. + */ +export const cursorRuntimeSlots: NativeAgentRuntimeSlots = { + runtimes: [ + { + id: "acp", + badge: "ACP", + installedTag: msg`ACP installed`, + notInstalledTag: msg`ACP not installed`, + installLabel: (environment) => + environment + ? msg`Install Cursor Agent (ACP) in ${environment}` + : msg`Install Cursor Agent (ACP)`, + installCommand: (project) => cursorRuntimeInstallCommand("acp", project), + detect: (status) => { + const state = cursorRuntimeInstallState(status); + return { + installed: state.acpInstalled, + ...(state.acpVersion ? { version: state.acpVersion } : {}), + }; + }, + }, + { + id: "sdk", + badge: "SDK", + installedTag: msg`SDK installed`, + notInstalledTag: msg`SDK not installed`, + installLabel: (environment) => + environment ? msg`Install Cursor SDK in ${environment}` : msg`Install Cursor SDK`, + installCommand: (project) => cursorRuntimeInstallCommand("sdk", project), + detect: (status) => { + const state = cursorRuntimeInstallState(status); + return { + installed: state.sdkInstalled, + ...(state.sdkVersion ? { version: state.sdkVersion } : {}), + ...(state.sdkInstallationSource + ? { installationSource: state.sdkInstallationSource } + : {}), + }; + }, + sourceLabel: cursorSdkSourceLabel, + update: { + actionLabel: (environment) => + environment ? msg`Update Cursor SDK in ${environment}` : msg`Update Cursor SDK`, + buttonLabel: msg`Update SDK`, + menuLabel: msg`Cursor SDK update targets`, + updatedToast: (nextVersion) => msg`Cursor SDK updated to v${nextVersion}.`, + upToDateToast: msg`Cursor SDK is up to date.`, + canUpdate: canUpdateCursorSdk, + command: cursorSdkUpdateCommand, + }, + }, + ], + bundle: { + id: "both", + installLabel: (environment) => + environment ? msg`Install ACP + SDK in ${environment}` : msg`Install ACP + SDK`, + installCommand: (project) => cursorRuntimeInstallCommand("both", project), + }, +}; diff --git a/src/renderer/views/SettingsOverlay/parts/nativeAgentRuntimes.ts b/src/renderer/views/SettingsOverlay/parts/nativeAgentRuntimes.ts new file mode 100644 index 000000000..067c052ad --- /dev/null +++ b/src/renderer/views/SettingsOverlay/parts/nativeAgentRuntimes.ts @@ -0,0 +1,125 @@ +import type { MessageDescriptor } from "@lingui/core"; +import type { AgentStatus, Project } from "@/shared/contracts"; + +/** + * Declarative description of the independently installable runtimes behind one + * native provider tile (e.g. Cursor's ACP CLI and its SDK package). Providers + * own the vocabulary — labels, commands, install-source names — while the + * shared registry card renders install targets, tags, versions and updates + * generically from these slots plus `AgentStatus.runtimeVariants`. + */ + +/** Resolves a lazily declared label at render time (`useLingui().t`). */ +export type RuntimeLabelTranslator = (descriptor: MessageDescriptor) => string; + +export interface NativeAgentRuntimeDetection { + installed: boolean; + version?: string; + installationSource?: string; +} + +/** A runtime the provider can install; also used for combined "install both" entries. */ +export interface NativeAgentRuntimeInstallOption { + /** Stable action-id suffix. Matches `runtimeVariants` keys for real runtimes. */ + id: string; + /** `environment` is set only when several install targets are offered. */ + installLabel: (environment: string | undefined) => MessageDescriptor; + installCommand: (project: Project) => string; +} + +export interface NativeAgentRuntimeUpdateSlot { + /** `environment` is set only when several environments offer the update. */ + actionLabel: (environment: string | undefined) => MessageDescriptor; + /** Idle label of the standalone update button. */ + buttonLabel: MessageDescriptor; + /** `aria-label` of the multi-environment update menu. */ + menuLabel: MessageDescriptor; + updatedToast: (version: string) => MessageDescriptor; + upToDateToast: MessageDescriptor; + /** False when this installation is not app-managed (no update action shown). */ + canUpdate: (status: AgentStatus) => boolean; + command: (status: AgentStatus, project: Project) => string | undefined; +} + +export interface NativeAgentRuntimeSlot extends NativeAgentRuntimeInstallOption { + /** Short untranslated acronym shown in the card footer (e.g. `ACP`). */ + badge: string; + installedTag: MessageDescriptor; + notInstalledTag: MessageDescriptor; + /** + * Provider-owned detection, for runtimes whose raw `runtimeVariants` entry + * needs compatibility rules (e.g. statuses cached before the variant existed). + * Defaults to reading `runtimeVariants[id]`. + */ + detect?: (status: AgentStatus | undefined) => NativeAgentRuntimeDetection; + /** Human label for `installationSource`; the vocabulary is provider-owned. */ + sourceLabel?: (source: string) => MessageDescriptor | string | undefined; + update?: NativeAgentRuntimeUpdateSlot; +} + +export interface NativeAgentRuntimeSlots { + runtimes: readonly NativeAgentRuntimeSlot[]; + /** Offered in addition to the individual runtimes when more than one is missing. */ + bundle?: NativeAgentRuntimeInstallOption; +} + +export function detectAgentRuntime( + slot: NativeAgentRuntimeSlot, + status: AgentStatus | undefined, +): NativeAgentRuntimeDetection { + if (slot.detect) return slot.detect(status); + const variant = status?.runtimeVariants?.[slot.id]; + return { + installed: variant?.installed ?? false, + ...(variant?.version ? { version: variant.version } : {}), + ...(variant?.installationSource ? { installationSource: variant.installationSource } : {}), + }; +} + +/** + * Install options for one environment: every runtime not yet detected there, + * plus the combined bundle when more than one is missing. + */ +export function availableRuntimeInstallOptions( + slots: NativeAgentRuntimeSlots, + status: AgentStatus | undefined, +): NativeAgentRuntimeInstallOption[] { + const missing = slots.runtimes.filter((slot) => !detectAgentRuntime(slot, status).installed); + if (missing.length > 1 && slots.bundle) return [...missing, slots.bundle]; + return missing; +} + +/** Runtime ids detected as installed in at least one of the given environments. */ +export function installedRuntimeIds( + slots: NativeAgentRuntimeSlots, + statuses: readonly AgentStatus[], +): ReadonlySet { + return new Set( + slots.runtimes + .filter((slot) => statuses.some((status) => detectAgentRuntime(slot, status).installed)) + .map((slot) => slot.id), + ); +} + +/** `ACP v1.2.3 · SDK v1.0.31 (global npm)` for one detected environment. */ +export function runtimeSummaryText( + slots: NativeAgentRuntimeSlots, + status: AgentStatus, + translate: RuntimeLabelTranslator, +): string { + const parts: string[] = []; + for (const slot of slots.runtimes) { + const detection = detectAgentRuntime(slot, status); + if (!detection.installed) continue; + const source = detection.installationSource + ? slot.sourceLabel?.(detection.installationSource) + : undefined; + const sourceLabel = + typeof source === "string" ? source : source ? translate(source) : undefined; + parts.push( + `${slot.badge}${detection.version ? ` v${detection.version}` : ""}` + + (sourceLabel ? ` (${sourceLabel})` : ""), + ); + } + return parts.join(" · "); +} diff --git a/src/shared/agentSelection.test.ts b/src/shared/agentSelection.test.ts index 2ece54085..d51a02e92 100644 --- a/src/shared/agentSelection.test.ts +++ b/src/shared/agentSelection.test.ts @@ -1,6 +1,9 @@ import { describe, expect, it } from "vitest"; -import type { AgentCapability } from "./contracts"; +import type { AgentCapability, AgentStatus, SessionRef } from "./contracts"; import { + agentStatusForPresentation, + authStatusForPresentation, + authStateForPresentation, capabilitiesForPresentation, modelSelectionFor, resolveModelSelection, @@ -34,6 +37,10 @@ const capabilities: AgentCapability = { }, }; +function sessionRef(providerSessionId: string): SessionRef { + return { providerSessionId, discoveredAt: "2026-07-27T00:00:00.000Z" }; +} + describe("agent selection", () => { it("resolves the composer surface before exposing model controls", () => { const gui = capabilitiesForPresentation(capabilities, "gui"); @@ -66,4 +73,171 @@ describe("agent selection", () => { "Fast is unavailable for this account", ); }); + + it("resolves presentation-specific authentication with a legacy fallback", () => { + expect( + authStateForPresentation( + { + authState: "authenticated", + presentationAuthStates: { gui: "missing" }, + }, + "gui", + ), + ).toBe("missing"); + expect( + authStateForPresentation( + { + authState: "authenticated", + presentationAuthStates: { gui: "missing" }, + }, + "terminal", + ), + ).toBe("authenticated"); + }); + + it("removes misleading provider login actions for externally authenticated runtimes", () => { + const status = authStatusForPresentation( + { + kind: "cursor", + label: "Cursor", + installed: true, + authState: "authenticated", + loginCommand: "cursor-agent login", + authMethods: [{ type: "terminal", id: "login", name: "Login", args: ["login"] }], + authLogoutSupported: true, + presentationAuthStates: { gui: "missing" }, + presentationAuthUsesProviderLogin: { gui: false }, + capabilities, + }, + "gui", + ); + + expect(status.authState).toBe("missing"); + expect(status.loginCommand).toBeUndefined(); + expect(status.authMethods).toBeUndefined(); + expect(status.authLogoutSupported).toBeUndefined(); + }); + + it("resolves authentication and capabilities as one presentation status", () => { + const status = agentStatusForPresentation( + { + kind: "cursor", + label: "Cursor", + installed: true, + authState: "missing", + loginCommand: "cursor-agent login", + presentationAuthStates: { gui: "authenticated" }, + presentationAuthUsesProviderLogin: { gui: false }, + capabilities, + }, + "gui", + ); + + expect(status.authState).toBe("authenticated"); + expect(status.loginCommand).toBeUndefined(); + expect(status.capabilities.models).toEqual([{ id: "chat-model", label: "Chat" }]); + expect(status.capabilities.defaultEffort).toBe("high"); + expect(status.capabilities.fastModels).toEqual(["chat-model"]); + expect(status.capabilities.presentationMode).toBe("gui"); + }); + + it("pins existing sessions to their runtime variant after the default changes", () => { + const { presentationCapabilities: _presentationCapabilities, ...baseCapabilities } = + capabilities; + const acpCapabilities: AgentCapability = { + ...baseCapabilities, + models: [{ id: "acp-model", label: "ACP" }], + liveInputMode: "server", + presentationMode: "gui", + }; + const sdkCapabilities: AgentCapability = { + ...acpCapabilities, + models: [{ id: "sdk-model", label: "SDK" }], + approvalPolicies: [{ id: "default", label: "Auto-review" }], + }; + const status: AgentStatus = { + kind: "cursor", + label: "Cursor", + installed: true, + authState: "authenticated", + loginCommand: "cursor-agent login", + authMethods: [{ type: "terminal", id: "login", name: "Login", args: ["login"] }], + authLogoutSupported: true, + presentationAuthStates: { gui: "missing" }, + presentationAuthUsesProviderLogin: { gui: false }, + capabilities: { + ...capabilities, + presentationCapabilities: { gui: sdkCapabilities }, + }, + runtimeVariants: { + acp: { + presentationMode: "gui", + installed: true, + authState: "authenticated", + authUsesProviderLogin: true, + capabilities: acpCapabilities, + }, + sdk: { + presentationMode: "gui", + installed: true, + authState: "missing", + authUsesProviderLogin: false, + capabilities: sdkCapabilities, + }, + }, + sessionRuntimeRouting: { + prefixes: { "sdk:": "sdk" }, + fallbackRuntime: "acp", + }, + }; + + const existingAcp = agentStatusForPresentation(status, "gui", sessionRef("legacy-acp-session")); + expect(existingAcp.authState).toBe("authenticated"); + expect(existingAcp.loginCommand).toBe("cursor-agent login"); + expect(existingAcp.capabilities.models).toEqual([{ id: "acp-model", label: "ACP" }]); + expect(agentStatusForPresentation(existingAcp, "gui").loginCommand).toBe("cursor-agent login"); + + const existingSdk = agentStatusForPresentation(status, "gui", sessionRef("sdk:agent-1")); + expect(existingSdk.authState).toBe("missing"); + expect(existingSdk.loginCommand).toBeUndefined(); + expect(existingSdk.authMethods).toBeUndefined(); + expect(existingSdk.capabilities.models).toEqual([{ id: "sdk-model", label: "SDK" }]); + }); + + it("uses the longest matching runtime prefix and ignores variants for another presentation", () => { + const status: AgentStatus = { + kind: "cursor", + label: "Cursor", + installed: true, + authState: "authenticated", + capabilities, + runtimeVariants: { + broad: { + presentationMode: "gui", + installed: true, + authState: "authenticated", + authUsesProviderLogin: true, + capabilities: { ...capabilities, models: [{ id: "broad", label: "Broad" }] }, + }, + specific: { + presentationMode: "gui", + installed: false, + authState: "missing", + authUsesProviderLogin: false, + capabilities: { ...capabilities, models: [{ id: "specific", label: "Specific" }] }, + }, + }, + sessionRuntimeRouting: { + prefixes: { "run:": "broad", "run:sdk:": "specific" }, + }, + }; + + const gui = agentStatusForPresentation(status, "gui", sessionRef("run:sdk:123")); + expect(gui.installed).toBe(false); + expect(gui.capabilities.models[0]?.id).toBe("specific"); + + const terminal = agentStatusForPresentation(status, "terminal", sessionRef("run:sdk:123")); + expect(terminal.installed).toBe(true); + expect(terminal.capabilities.models[0]?.id).toBe("terminal-model"); + }); }); diff --git a/src/shared/agentSelection.ts b/src/shared/agentSelection.ts index 7ee2335f7..f3d1c0bc9 100644 --- a/src/shared/agentSelection.ts +++ b/src/shared/agentSelection.ts @@ -1,4 +1,11 @@ -import type { AgentCapability, ThreadPresentationMode } from "./contracts"; +import type { + AgentCapability, + AgentRuntimeVariant, + AgentStatus, + AuthState, + SessionRef, + ThreadPresentationMode, +} from "./contracts"; export interface AgentModelSelection { reasoning: { @@ -12,6 +19,49 @@ export interface AgentModelSelection { }; } +export function authStateForPresentation( + status: Pick, + presentationMode: ThreadPresentationMode, +): AuthState { + return status.presentationAuthStates?.[presentationMode] ?? status.authState; +} + +export function authStatusForPresentation( + status: AgentStatus, + presentationMode: ThreadPresentationMode, +): AgentStatus { + const authState = authStateForPresentation(status, presentationMode); + if (status.presentationAuthUsesProviderLogin?.[presentationMode] !== false) { + return authState === status.authState ? status : { ...status, authState }; + } + return stripProviderLogin({ ...status, authState }); +} + +function stripProviderLogin(status: AgentStatus): AgentStatus { + const { + loginCommand: _loginCommand, + authMethods: _authMethods, + authLogoutSupported: _authLogoutSupported, + preferTerminalLogin: _preferTerminalLogin, + ...withoutProviderLogin + } = status; + return withoutProviderLogin; +} + +function restoreProviderLogin(source: AgentStatus, status: AgentStatus): AgentStatus { + return { + ...status, + ...(source.loginCommand !== undefined ? { loginCommand: source.loginCommand } : {}), + ...(source.authMethods !== undefined ? { authMethods: source.authMethods } : {}), + ...(source.authLogoutSupported !== undefined + ? { authLogoutSupported: source.authLogoutSupported } + : {}), + ...(source.preferTerminalLogin !== undefined + ? { preferTerminalLogin: source.preferTerminalLogin } + : {}), + }; +} + export function capabilitiesForPresentation( capabilities: AgentCapability, presentationMode: ThreadPresentationMode, @@ -49,6 +99,78 @@ export function capabilitiesForPresentation( }; } +/** + * Resolve every presentation-scoped part of an agent status together. + * + * Consumers should derive this once for the active thread/draft and pass the + * returned status through the rest of the flow. That keeps authentication, + * models, slash commands, input behavior, and safety defaults on the same + * runtime surface. + */ +export function agentStatusForPresentation( + status: AgentStatus, + presentationMode: ThreadPresentationMode, + sessionRef?: SessionRef, +): AgentStatus { + const presentationStatus = { + ...authStatusForPresentation(status, presentationMode), + capabilities: capabilitiesForPresentation(status.capabilities, presentationMode), + }; + const runtimeVariant = runtimeVariantForSession(status, presentationMode, sessionRef); + if (!runtimeVariant) { + return presentationStatus; + } + + const runtimeStatus: AgentStatus = { + ...presentationStatus, + installed: runtimeVariant.installed, + authState: runtimeVariant.authState, + presentationAuthStates: { + ...presentationStatus.presentationAuthStates, + [presentationMode]: runtimeVariant.authState, + }, + presentationAuthUsesProviderLogin: { + ...presentationStatus.presentationAuthUsesProviderLogin, + [presentationMode]: runtimeVariant.authUsesProviderLogin, + }, + capabilities: runtimeVariant.capabilities, + }; + return runtimeVariant.authUsesProviderLogin + ? restoreProviderLogin(status, runtimeStatus) + : stripProviderLogin(runtimeStatus); +} + +function runtimeVariantForSession( + status: AgentStatus, + presentationMode: ThreadPresentationMode, + sessionRef: SessionRef | undefined, +): AgentRuntimeVariant | undefined { + const providerSessionId = sessionRef?.providerSessionId; + const variants = status.runtimeVariants; + const routing = status.sessionRuntimeRouting; + if (!providerSessionId || !variants || !routing) { + return undefined; + } + + let matchedRuntime: string | undefined; + let matchedPrefixLength = -1; + for (const [prefix, runtime] of Object.entries(routing.prefixes)) { + const variant = variants[runtime]; + if ( + prefix.length > matchedPrefixLength && + providerSessionId.startsWith(prefix) && + variant?.presentationMode === presentationMode + ) { + matchedRuntime = runtime; + matchedPrefixLength = prefix.length; + } + } + + const runtime = matchedRuntime ?? routing.fallbackRuntime; + const variant = runtime ? variants[runtime] : undefined; + return variant?.presentationMode === presentationMode ? variant : undefined; +} + /** Return capabilities with hidden models filtered out. */ export function filterHiddenModels( capabilities: AgentCapability, diff --git a/src/shared/contracts/agent.test.ts b/src/shared/contracts/agent.test.ts new file mode 100644 index 000000000..e43007b7b --- /dev/null +++ b/src/shared/contracts/agent.test.ts @@ -0,0 +1,139 @@ +import { describe, expect, it } from "vitest"; +import { agentStatusSchema, areAgentPresentationRuntimeFieldsEqual } from "./agent"; + +describe("agentStatusSchema runtime variants", () => { + it("parses named runtime variants with full effective capability defaults and routing", () => { + const parsed = agentStatusSchema.parse({ + kind: "cursor", + label: "Cursor", + installed: true, + authState: "authenticated", + capabilities: {}, + runtimeVariants: { + sdk: { + presentationMode: "gui", + installed: true, + authState: "missing", + authUsesProviderLogin: false, + capabilities: { + models: [{ id: "sdk-model", label: "SDK Model" }], + presentationMode: "gui", + liveInputMode: "server", + }, + }, + }, + sessionRuntimeRouting: { + prefixes: { "sdk:": "sdk" }, + fallbackRuntime: "acp", + }, + }); + + expect(parsed.runtimeVariants?.sdk).toMatchObject({ + presentationMode: "gui", + installed: true, + authState: "missing", + authUsesProviderLogin: false, + capabilities: { + models: [{ id: "sdk-model", label: "SDK Model" }], + efforts: [], + modelEfforts: {}, + modes: [], + approvalPolicies: [], + sandboxModes: [], + supportsResume: false, + supportsDirectInput: true, + liveInputMode: "server", + presentationMode: "gui", + settingDefs: [], + }, + }); + expect(parsed.sessionRuntimeRouting).toEqual({ + prefixes: { "sdk:": "sdk" }, + fallbackRuntime: "acp", + }); + }); + + it("rejects empty session prefixes and incomplete runtime metadata", () => { + const base = { + kind: "cursor", + label: "Cursor", + installed: true, + authState: "authenticated", + capabilities: {}, + }; + expect( + agentStatusSchema.safeParse({ + ...base, + runtimeVariants: { + sdk: { + presentationMode: "gui", + installed: true, + authState: "authenticated", + capabilities: {}, + }, + }, + }).success, + ).toBe(false); + expect( + agentStatusSchema.safeParse({ + ...base, + sessionRuntimeRouting: { + prefixes: { "": "sdk" }, + }, + }).success, + ).toBe(false); + }); +}); + +describe("areAgentPresentationRuntimeFieldsEqual", () => { + const variant = { + presentationMode: "gui" as const, + installed: true, + authState: "authenticated" as const, + authUsesProviderLogin: true, + capabilities: agentStatusSchema.parse({ + kind: "cursor", + label: "Cursor", + installed: true, + authState: "authenticated", + capabilities: {}, + }).capabilities, + }; + + it("treats absent and empty nested payloads as equal", () => { + expect(areAgentPresentationRuntimeFieldsEqual({}, {})).toBe(true); + expect( + areAgentPresentationRuntimeFieldsEqual( + {}, + { presentationAuthStates: {}, runtimeVariants: {} }, + ), + ).toBe(true); + }); + + it("detects changes in each presentation/runtime field", () => { + expect( + areAgentPresentationRuntimeFieldsEqual({}, { presentationAuthStates: { gui: "missing" } }), + ).toBe(false); + expect( + areAgentPresentationRuntimeFieldsEqual( + {}, + { presentationAuthUsesProviderLogin: { gui: false } }, + ), + ).toBe(false); + expect(areAgentPresentationRuntimeFieldsEqual({}, { runtimeVariants: { sdk: variant } })).toBe( + false, + ); + expect( + areAgentPresentationRuntimeFieldsEqual( + { sessionRuntimeRouting: { prefixes: { "sdk:": "sdk" } } }, + { sessionRuntimeRouting: { prefixes: { "sdk:": "acp" } } }, + ), + ).toBe(false); + expect( + areAgentPresentationRuntimeFieldsEqual( + { runtimeVariants: { sdk: variant }, presentationAuthStates: { gui: "missing" } }, + { runtimeVariants: { sdk: variant }, presentationAuthStates: { gui: "missing" } }, + ), + ).toBe(true); + }); +}); diff --git a/src/shared/contracts/agent.ts b/src/shared/contracts/agent.ts index 866e13e85..af2f0e847 100644 --- a/src/shared/contracts/agent.ts +++ b/src/shared/contracts/agent.ts @@ -153,6 +153,8 @@ const bypassPermissionsSchema = z.object({ const agentPresentationCapabilityOverrideSchema = z .object({ + /** Short provider-owned runtime badge shown in structured composers (for example ACP / SDK). */ + runtimeLabel: z.string().min(1).optional(), models: z.array(labeledOptionSchema), efforts: z.array(z.string().min(1)), defaultEffort: z.string().optional(), @@ -211,6 +213,8 @@ export const composerMcpScopesSchema = z.object({ export type ComposerMcpScopes = z.infer; export const agentCapabilitySchema = z.object({ + /** Short provider-owned runtime badge shown in structured composers (for example ACP / SDK). */ + runtimeLabel: z.string().min(1).optional(), models: z.array(labeledOptionSchema).default([]), efforts: z.array(z.string().min(1)).default([]), defaultEffort: z.string().optional(), @@ -333,6 +337,38 @@ export const agentCapabilitySchema = z.object({ }); export type AgentCapability = z.infer; +/** + * One independently detected runtime behind a provider tile. + * + * `capabilities` is deliberately the complete, already-resolved capability + * surface for this runtime rather than a presentation override. That lets an + * existing thread stay pinned to its original runtime when the provider's + * default changes later. + */ +export const agentRuntimeVariantSchema = z.object({ + presentationMode: threadPresentationModeSchema, + installed: z.boolean(), + /** Runtime-specific package version when the provider exposes one. */ + version: z.string().min(1).optional(), + /** Provider-owned installation source used to explain lifecycle ownership. */ + installationSource: z.string().min(1).optional(), + authState: authStateSchema, + authUsesProviderLogin: z.boolean(), + capabilities: agentCapabilitySchema, +}); +export type AgentRuntimeVariant = z.infer; + +/** + * Provider-owned mapping from opaque provider session ids to named runtime + * variants. Shared consumers only apply the generic prefix/fallback rules; + * provider-specific discriminators stay at the provider boundary. + */ +export const agentSessionRuntimeRoutingSchema = z.object({ + prefixes: z.record(z.string().min(1), z.string().min(1)), + fallbackRuntime: z.string().min(1).optional(), +}); +export type AgentSessionRuntimeRouting = z.infer; + export const agentStatusSchema = z.object({ kind: agentKindSchema, label: z.string().min(1), @@ -342,6 +378,31 @@ export const agentStatusSchema = z.object({ version: z.string().optional(), update: agentUpdateInfoSchema.optional(), authState: authStateSchema, + /** + * Authentication can differ between presentation runtimes even when they + * share one provider tile. For example, Cursor's terminal/ACP surfaces use + * the installed CLI login while its SDK GUI surface requires + * `CURSOR_API_KEY`. Consumers fall back to `authState` when no override is + * present. + */ + presentationAuthStates: z + .object({ + terminal: authStateSchema.optional(), + gui: authStateSchema.optional(), + }) + .optional(), + /** + * Whether the provider's ordinary login command / ACP auth methods apply to + * a presentation-specific auth state. Set false for runtimes that require + * external credentials (such as Cursor SDK API keys) so the UI does not + * offer a misleading CLI login action. + */ + presentationAuthUsesProviderLogin: z + .object({ + terminal: z.boolean().optional(), + gui: z.boolean().optional(), + }) + .optional(), loginCommand: z.string().min(1).optional(), /** * Prefer the terminal `loginCommand` over agent-owned/browser auth methods @@ -353,6 +414,10 @@ export const agentStatusSchema = z.object({ authMethods: z.array(agentAuthMethodSchema).optional(), authLogoutSupported: z.boolean().optional(), capabilities: agentCapabilitySchema, + /** Named, independently detected runtimes available behind this provider. */ + runtimeVariants: z.record(z.string().min(1), agentRuntimeVariantSchema).optional(), + /** Session-id routing into `runtimeVariants` for existing threads. */ + sessionRuntimeRouting: agentSessionRuntimeRoutingSchema.optional(), envKind: z.enum(["windows", "wsl", "posix"]).optional(), envDistro: z.string().optional(), }); @@ -635,6 +700,43 @@ export function areAgentProviderMetadataEqual( ); } +/** + * Compare the presentation- and runtime-scoped status fields that carry nested + * provider-owned shapes (per-presentation auth, named runtime variants, session + * runtime routing). Structural equality via `JSON.stringify` is intentional: + * these payloads are adapter-produced, key-order-stable, and cheap to serialize, + * and every consumer only needs "did the provider report something different". + * + * Shared by the renderer status store and the status slice so their change + * detection cannot drift apart. + */ +export function areAgentPresentationRuntimeFieldsEqual( + left: Pick< + AgentStatus, + | "presentationAuthStates" + | "presentationAuthUsesProviderLogin" + | "runtimeVariants" + | "sessionRuntimeRouting" + >, + right: Pick< + AgentStatus, + | "presentationAuthStates" + | "presentationAuthUsesProviderLogin" + | "runtimeVariants" + | "sessionRuntimeRouting" + >, +): boolean { + return ( + JSON.stringify(left.presentationAuthStates ?? {}) === + JSON.stringify(right.presentationAuthStates ?? {}) && + JSON.stringify(left.presentationAuthUsesProviderLogin ?? {}) === + JSON.stringify(right.presentationAuthUsesProviderLogin ?? {}) && + JSON.stringify(left.runtimeVariants ?? {}) === JSON.stringify(right.runtimeVariants ?? {}) && + JSON.stringify(left.sessionRuntimeRouting ?? {}) === + JSON.stringify(right.sessionRuntimeRouting ?? {}) + ); +} + export function compactAgentProviderMetadata( metadata: AgentProviderMetadata | undefined, ): AgentProviderMetadata | undefined { diff --git a/src/shared/promptContent.ts b/src/shared/promptContent.ts index 048b08f3a..0b420688d 100644 --- a/src/shared/promptContent.ts +++ b/src/shared/promptContent.ts @@ -41,6 +41,11 @@ export function isImagePath(path: string, mimeType?: string): boolean { return mimeType?.startsWith("image/") === true || IMAGE_EXTENSIONS.has(getExtension(path)); } +/** The image MIME type implied by a path's extension, when it is a known one. */ +export function mimeForImagePath(path: string): string | undefined { + return MIME_BY_EXT[getExtension(path)]; +} + export function isPdfPath(path: string, mimeType?: string): boolean { return mimeType === "application/pdf" || getExtension(path) === "pdf"; } diff --git a/src/supervisor/agents/base/index.ts b/src/supervisor/agents/base/index.ts index e019f1ca6..b84b3fc16 100644 --- a/src/supervisor/agents/base/index.ts +++ b/src/supervisor/agents/base/index.ts @@ -654,7 +654,13 @@ export async function detectAgentInstall( let probedProviderMetadata: AgentProviderMetadata | undefined; let probedPreferTerminalLogin: boolean | undefined; if (executablePath) { - const probeCtx: DetectProbeCtx = { location, executablePath, version, probeEnv: spec.probeEnv }; + const probeCtx: DetectProbeCtx = { + location, + executablePath, + version, + ...(ctx?.agentSettings ? { agentSettings: ctx.agentSettings } : {}), + probeEnv: spec.probeEnv, + }; const [capabilityPartial, nextStatusProbeResult] = await Promise.all([ spec.capabilitiesProbe ? spec.capabilitiesProbe(probeCtx) : Promise.resolve(undefined), spec.statusProbe ? spec.statusProbe(probeCtx) : Promise.resolve(undefined), @@ -688,7 +694,12 @@ export async function detectAgentInstall( statusProbeResult = nextStatusProbeResult; } - const probeCtx: DetectProbeCtx = { location, executablePath, version }; + const probeCtx: DetectProbeCtx = { + location, + executablePath, + version, + ...(ctx?.agentSettings ? { agentSettings: ctx.agentSettings } : {}), + }; let authState: AuthState; if (!executablePath) { diff --git a/src/supervisor/agents/base/types.ts b/src/supervisor/agents/base/types.ts index d4534f4c6..b133d2f3e 100644 --- a/src/supervisor/agents/base/types.ts +++ b/src/supervisor/agents/base/types.ts @@ -40,6 +40,12 @@ export interface CommandSpec { export interface AgentEnvContext { envKind: "windows" | "wsl" | "posix"; wslDistro?: string; + /** + * Provider-global settings for this adapter. Detection receives the same + * snapshot launch receives, allowing a provider with multiple structured + * runtimes to probe only the selected runtime. + */ + agentSettings?: Record; /** * Poracode data base dir for native (non-WSL) plugin staging. Populated by * the supervisor so dev runs (`~/.poracode-dev`) stage plugins separately @@ -232,6 +238,7 @@ export interface DetectProbeCtx { location: ProjectLocation; executablePath: string | undefined; version?: string | undefined; + agentSettings?: Record; /** {@link DetectionSpec.probeEnv}, so `capabilitiesProbe`/`statusProbe` can forward it. */ probeEnv?: Record | undefined; } diff --git a/src/supervisor/agents/cursor/cursor.test.ts b/src/supervisor/agents/cursor/cursor.test.ts index c2f40a3a2..e08ae39cb 100644 --- a/src/supervisor/agents/cursor/cursor.test.ts +++ b/src/supervisor/agents/cursor/cursor.test.ts @@ -57,6 +57,29 @@ describe("createCursorAdapter capabilities", () => { expect(adapter.capabilities.presentationModes).toEqual(["terminal", "gui"]); expect(adapter.createStructuredSession).toBeTypeOf("function"); }); + + it("does not pass SDK-local session ids to cursor-agent context extraction", () => { + const adapter = createCursorAdapter(); + const location = { kind: "posix" as const, path: "/repo" }; + expect( + adapter.buildContextExtractionCommand?.( + { + providerSessionId: "sdk:agent-123", + discoveredAt: "2026-07-27T00:00:00.000Z", + }, + location, + ), + ).toBeUndefined(); + expect( + adapter.buildContextExtractionCommand?.( + { + providerSessionId: "cli-chat-123", + discoveredAt: "2026-07-27T00:00:00.000Z", + }, + location, + )?.args, + ).toContain("--resume=cli-chat-123"); + }); }); describe("rewriteCursorLoadSessionError", () => { diff --git a/src/supervisor/agents/cursor/detection.ts b/src/supervisor/agents/cursor/detection.ts index 1ff3bb384..630cb3144 100644 --- a/src/supervisor/agents/cursor/detection.ts +++ b/src/supervisor/agents/cursor/detection.ts @@ -26,6 +26,7 @@ import { } from "../base"; import { dedupeAcpAuthMethods, probeAcpCapabilities } from "../acp"; import { getAgentProbeCwd, resolveProbeSpawnCwd } from "../probeCwd"; +import { cursorModelGrouping } from "./modelGrouping"; export const cursorDefaultCapabilities: AgentCapability = { models: [], @@ -241,6 +242,8 @@ export function buildCursorModelPickerCapabilities( | "efforts" | "defaultEffort" | "modelEfforts" + | "subProviders" + | "modelSubProvider" | "contextSizes" | "modelContextSizes" | "defaultContextSize" @@ -343,6 +346,7 @@ export function buildCursorModelPickerCapabilities( return { models: displayModels, + ...cursorModelGrouping(displayModels), efforts: sortCursorEffortIds([...effortIds]), ...(effortIds.has("medium") ? { defaultEffort: "medium" } : {}), modelEfforts, @@ -362,7 +366,10 @@ function formatCursorAcpModelLabel(model: LabeledOption): string { export function buildCursorAcpModelPickerCapabilities( models: LabeledOption[], -): Pick { +): Pick< + AgentCapability, + "models" | "efforts" | "modelEfforts" | "subProviders" | "modelSubProvider" +> { const displayModels = models.map((model) => ({ id: model.id, label: formatCursorAcpModelLabel(model), @@ -371,6 +378,7 @@ export function buildCursorAcpModelPickerCapabilities( return { models: sortedModels, + ...cursorModelGrouping(sortedModels), efforts: [], modelEfforts: Object.fromEntries(sortedModels.map((model) => [model.id, []])), }; diff --git a/src/supervisor/agents/cursor/index.ts b/src/supervisor/agents/cursor/index.ts index 948d305c0..664da06ab 100644 --- a/src/supervisor/agents/cursor/index.ts +++ b/src/supervisor/agents/cursor/index.ts @@ -1,4 +1,4 @@ -import type { AgentCapability, PromptSegment } from "@/shared/contracts"; +import type { PromptSegment } from "@/shared/contracts"; import { inlinePromptSegmentText } from "@/shared/promptContent"; import { createAcpStructuredSession } from "../acp"; import { @@ -30,6 +30,13 @@ import { } from "./plugin/install"; import { createCursorChatSync } from "./session"; import { CURSOR_IDLE_RE, CURSOR_WORKING_RE, detectCursorTerminalStatus } from "./terminal"; +import { applyCursorSdkProbe, probeCursorSdkRuntime } from "./sdkDetection"; +import { CursorSdkSession } from "./sdkSession"; +import { + configuredCursorStructuredRuntime, + CURSOR_SDK_SESSION_PREFIX, + resolveCursorStructuredRuntime, +} from "./structuredRuntime"; export { buildCursorAcpModelPickerCapabilities, @@ -68,8 +75,6 @@ export function rewriteCursorLoadSessionError(error: unknown, _sessionId: string } export function createCursorAdapter(): AgentAdapter { - let capabilities: AgentCapability = cursorDefaultCapabilities; - return { kind: cursorDetectionSpec.kind, label: cursorDetectionSpec.label, @@ -117,9 +122,11 @@ export function createCursorAdapter(): AgentAdapter { }, }, ...(cursorDetectionSpec.update ? { update: cursorDetectionSpec.update } : {}), - get capabilities() { - return capabilities; - }, + // Detection returns environment-scoped capabilities through AgentStatus. + // Keep the adapter's process-wide fallback immutable: native and WSL + // probes run concurrently, and a mutable singleton lets the last probe + // change routing for every other environment. + capabilities: cursorDefaultCapabilities, spawnEnv: { wsl: { BROWSER: "/bin/true" } }, pluginId: "poracode-status@cursor", pluginVersion: CURSOR_PLUGIN_VERSION, @@ -143,9 +150,15 @@ export function createCursorAdapter(): AgentAdapter { }, detectInstall: async (ctx) => { - const status = await detectAgentInstall(ctx, cursorDetectionSpec); - capabilities = status.capabilities; - return status; + const [cliStatus, sdkProbe] = await Promise.all([ + detectAgentInstall(ctx, cursorDetectionSpec), + probeCursorSdkRuntime(ctx), + ]); + return applyCursorSdkProbe( + cliStatus, + sdkProbe, + configuredCursorStructuredRuntime(ctx?.agentSettings), + ); }, // Cursor CLI has no documented per-launch MCP config or isolated config // home. Do not project launchOptions.mcpServers into the user's persistent @@ -167,6 +180,12 @@ export function createCursorAdapter(): AgentAdapter { return undefined; }, async createStructuredSession(input: CreateStructuredSessionInput) { + if (input.presentationMode === "gui") { + const resolved = resolveCursorStructuredRuntime(input.agentSettings, input.sessionRef); + if (resolved.runtime === "sdk") { + return CursorSdkSession.create(input); + } + } const command = buildAgentCommand( input.projectLocation, "cursor-agent", @@ -226,6 +245,10 @@ export function createCursorAdapter(): AgentAdapter { return { command: "cursor-agent", args }; }, buildContextExtractionCommand(sessionRef, _location, model) { + // `sdk:` identifies Cursor's SDK-local Agent store, not a cursor-agent + // CLI chat. Passing it to `cursor-agent --resume` can open the wrong + // conversation or fail with an invalid session id. + if (sessionRef.providerSessionId.startsWith(CURSOR_SDK_SESSION_PREFIX)) return undefined; const args = [ "--print", "--force", diff --git a/src/supervisor/agents/cursor/modelGrouping.ts b/src/supervisor/agents/cursor/modelGrouping.ts new file mode 100644 index 000000000..dac6ff724 --- /dev/null +++ b/src/supervisor/agents/cursor/modelGrouping.ts @@ -0,0 +1,33 @@ +import type { AgentCapability, LabeledOption } from "@/shared/contracts"; +import { parseCursorModelId } from "@/shared/cursorModelId"; + +/** + * Sub-provider grouping shared by every Cursor model picker projection (CLI + * terminal models, ACP models, and the SDK catalog). Kept in its own module so + * the SDK projection doesn't have to import the detection module's process + * probes just to classify a model id. + */ + +export const CURSOR_MODEL_GROUP_ID = "cursor"; +export const OTHER_MODEL_GROUP_ID = "other"; + +const CURSOR_FIRST_PARTY_MODEL_RE = /^(?:auto(?:-smart)?|composer(?:-|$))/iu; + +export function cursorModelGroup(modelId: string): "cursor" | "other" { + const baseId = parseCursorModelId(modelId).baseId; + return CURSOR_FIRST_PARTY_MODEL_RE.test(baseId) ? CURSOR_MODEL_GROUP_ID : OTHER_MODEL_GROUP_ID; +} + +export function cursorModelGrouping( + models: readonly LabeledOption[], +): Pick { + return { + subProviders: [ + { id: CURSOR_MODEL_GROUP_ID, label: "Cursor Models" }, + { id: OTHER_MODEL_GROUP_ID, label: "Other models" }, + ], + modelSubProvider: Object.fromEntries( + models.map((model) => [model.id, cursorModelGroup(model.id)]), + ), + }; +} diff --git a/src/supervisor/agents/cursor/sdkAdapterDetection.test.ts b/src/supervisor/agents/cursor/sdkAdapterDetection.test.ts new file mode 100644 index 000000000..0f6147b7b --- /dev/null +++ b/src/supervisor/agents/cursor/sdkAdapterDetection.test.ts @@ -0,0 +1,147 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { AgentStatus } from "@/shared/contracts"; +import type { AgentEnvContext, DetectionSpec } from "../base"; +import type { CursorSdkRuntimeProbe } from "./sdkDetection"; +import type { CursorStructuredRuntime } from "./structuredRuntime"; + +const mocks = vi.hoisted(() => ({ + detectAgentInstall: + vi.fn<(ctx: AgentEnvContext | undefined, spec: DetectionSpec) => Promise>(), + probeCursorSdkRuntime: + vi.fn<(ctx: AgentEnvContext | undefined) => Promise>(), + applyCursorSdkProbe: + vi.fn< + ( + status: AgentStatus, + probe: CursorSdkRuntimeProbe, + selectedRuntime: CursorStructuredRuntime, + ) => AgentStatus + >(), +})); + +vi.mock("../base", async (importActual) => { + const actual = await importActual(); + return { ...actual, detectAgentInstall: mocks.detectAgentInstall }; +}); + +vi.mock("./sdkDetection", () => ({ + probeCursorSdkRuntime: mocks.probeCursorSdkRuntime, + applyCursorSdkProbe: mocks.applyCursorSdkProbe, +})); + +vi.mock("../binaryResolver", () => ({ + resolveAgentBinaryPath: () => undefined, +})); + +vi.mock("../base/processRuntime", async (importActual) => { + const actual = await importActual(); + return { ...actual, resolveWslShellPath: () => "/bin/bash" }; +}); + +import { createCursorAdapter } from "./index"; + +const cliStatus: AgentStatus = { + kind: "cursor", + label: "Cursor", + installed: true, + authState: "authenticated", + capabilities: { + models: [{ id: "cli-model", label: "CLI Model" }], + efforts: [], + modelEfforts: {}, + modes: ["agent", "plan"], + approvalPolicies: [], + sandboxModes: [], + supportsResume: true, + supportsDirectInput: true, + liveInputMode: "terminal", + presentationMode: "terminal", + presentationModes: ["terminal", "gui"], + settingDefs: [], + }, +}; + +describe("Cursor adapter SDK detection selection", () => { + beforeEach(() => { + vi.clearAllMocks(); + mocks.detectAgentInstall.mockResolvedValue(cliStatus); + mocks.probeCursorSdkRuntime.mockResolvedValue({ + installed: true, + authState: "authenticated", + models: [{ id: "sdk-model", displayName: "SDK Model" }], + }); + mocks.applyCursorSdkProbe.mockReturnValue({ + ...cliStatus, + presentationAuthStates: { terminal: "authenticated", gui: "authenticated" }, + }); + }); + + it("probes and merges the external SDK when SDK mode is selected", async () => { + const adapter = createCursorAdapter(); + const context = { + envKind: "posix" as const, + agentSettings: { structuredRuntime: "sdk" }, + }; + const result = await adapter.detectInstall(context); + + expect(mocks.detectAgentInstall).toHaveBeenCalledWith(context, expect.any(Object)); + expect(mocks.probeCursorSdkRuntime).toHaveBeenCalledWith(context); + expect(mocks.applyCursorSdkProbe).toHaveBeenCalledWith( + cliStatus, + expect.objectContaining({ models: [{ id: "sdk-model", displayName: "SDK Model" }] }), + "sdk", + ); + expect(result.presentationAuthStates?.gui).toBe("authenticated"); + }); + + it("still probes and retains the SDK runtime while ACP is selected", async () => { + const adapter = createCursorAdapter(); + const context = { envKind: "posix" as const }; + await adapter.detectInstall(context); + expect(mocks.probeCursorSdkRuntime).toHaveBeenCalledWith(context); + expect(mocks.applyCursorSdkProbe).toHaveBeenCalledWith( + cliStatus, + expect.objectContaining({ models: [{ id: "sdk-model", displayName: "SDK Model" }] }), + "acp", + ); + }); + + it("keeps process-wide routing immutable across divergent environment probes", async () => { + mocks.applyCursorSdkProbe.mockImplementation((status, probe) => ({ + ...status, + capabilities: { + ...status.capabilities, + liveInputMode: probe.models[0]?.id === "wsl-model" ? "server" : "terminal", + presentationMode: "gui", + }, + })); + mocks.probeCursorSdkRuntime.mockImplementation(async (context) => ({ + installed: true, + authState: "authenticated", + models: [ + { + id: context?.envKind === "wsl" ? "wsl-model" : "native-model", + displayName: "SDK Model", + }, + ], + })); + const adapter = createCursorAdapter(); + + const [native, wsl] = await Promise.all([ + adapter.detectInstall({ + envKind: "posix", + agentSettings: { structuredRuntime: "sdk" }, + }), + adapter.detectInstall({ + envKind: "wsl", + wslDistro: "Ubuntu", + agentSettings: { structuredRuntime: "sdk" }, + }), + ]); + + expect(native.capabilities.liveInputMode).toBe("terminal"); + expect(wsl.capabilities.liveInputMode).toBe("server"); + expect(adapter.capabilities.liveInputMode).toBe("terminal"); + expect(adapter.capabilities.presentationMode).toBe("terminal"); + }); +}); diff --git a/src/supervisor/agents/cursor/sdkAdapterRuntime.test.ts b/src/supervisor/agents/cursor/sdkAdapterRuntime.test.ts new file mode 100644 index 000000000..fed9c66f2 --- /dev/null +++ b/src/supervisor/agents/cursor/sdkAdapterRuntime.test.ts @@ -0,0 +1,119 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { CreateStructuredSessionInput, StructuredSessionHandle } from "../base"; + +const mocks = vi.hoisted(() => ({ + createAcpStructuredSession: vi.fn<(...args: unknown[]) => StructuredSessionHandle>(), + createSdkSession: + vi.fn<(input: CreateStructuredSessionInput) => Promise>(), +})); + +vi.mock("../acp", async (importActual) => { + const actual = await importActual(); + return { + ...actual, + createAcpStructuredSession: mocks.createAcpStructuredSession, + }; +}); + +vi.mock("./sdkSession", () => ({ + CursorSdkSession: { create: mocks.createSdkSession }, +})); + +vi.mock("../binaryResolver", () => ({ + resolveAgentBinaryPath: () => "/usr/local/bin/cursor-agent", +})); + +vi.mock("../base/processRuntime", async (importActual) => { + const actual = await importActual(); + return { ...actual, resolveWslShellPath: () => "/bin/bash" }; +}); + +import { createCursorAdapter } from "./index"; + +function session(name: string): StructuredSessionHandle { + return { + launchOptions: {}, + setListener: () => undefined, + dispose: vi.fn<() => Promise>().mockResolvedValue(undefined), + [Symbol.for("testName")]: name, + } as unknown as StructuredSessionHandle; +} + +function input( + overrides: Partial = {}, +): CreateStructuredSessionInput { + return { + threadId: "thread-1", + projectLocation: { kind: "posix", path: "/repo" }, + config: { model: "composer-2.5" }, + presentationMode: "gui", + ...overrides, + }; +} + +describe("Cursor structured runtime selection", () => { + beforeEach(() => { + vi.clearAllMocks(); + mocks.createAcpStructuredSession.mockReturnValue(session("acp")); + mocks.createSdkSession.mockResolvedValue(session("sdk")); + }); + + it("uses external SDK mode for a fresh GUI thread when selected", async () => { + const adapter = createCursorAdapter(); + const request = input({ agentSettings: { structuredRuntime: "sdk" } }); + + const result = await adapter.createStructuredSession?.(request); + + expect(result).toBe(await mocks.createSdkSession.mock.results[0]!.value); + expect(mocks.createSdkSession).toHaveBeenCalledWith(request); + expect(mocks.createAcpStructuredSession).not.toHaveBeenCalled(); + }); + + it("pins an SDK resume even after the provider default changes back to ACP", async () => { + const adapter = createCursorAdapter(); + const request = input({ + agentSettings: { structuredRuntime: "acp" }, + sessionRef: { + providerSessionId: "sdk:agent-123", + discoveredAt: "2026-07-27T00:00:00.000Z", + }, + }); + + await adapter.createStructuredSession?.(request); + + expect(mocks.createSdkSession).toHaveBeenCalledWith(request); + expect(mocks.createAcpStructuredSession).not.toHaveBeenCalled(); + }); + + it("keeps historical unprefixed GUI resumes on ACP", async () => { + const adapter = createCursorAdapter(); + const request = input({ + agentSettings: { structuredRuntime: "sdk" }, + sessionRef: { + providerSessionId: "legacy-acp-session", + discoveredAt: "2026-07-27T00:00:00.000Z", + }, + }); + + await adapter.createStructuredSession?.(request); + + expect(mocks.createSdkSession).not.toHaveBeenCalled(); + expect(mocks.createAcpStructuredSession).toHaveBeenCalledOnce(); + expect(mocks.createAcpStructuredSession.mock.calls[0]?.[0]).toMatchObject({ + args: ["acp"], + }); + }); + + it("never replaces the terminal presentation's installed-agent flow", async () => { + const adapter = createCursorAdapter(); + await adapter.createStructuredSession?.( + input({ + presentationMode: "terminal", + agentSettings: { structuredRuntime: "sdk" }, + }), + ); + + expect(mocks.createSdkSession).not.toHaveBeenCalled(); + expect(mocks.createAcpStructuredSession).toHaveBeenCalledOnce(); + }); +}); diff --git a/src/supervisor/agents/cursor/sdkCanonicalMapping.test.ts b/src/supervisor/agents/cursor/sdkCanonicalMapping.test.ts new file mode 100644 index 000000000..dc37bcb7f --- /dev/null +++ b/src/supervisor/agents/cursor/sdkCanonicalMapping.test.ts @@ -0,0 +1,1568 @@ +import { describe, expect, it } from "vitest"; +import { runtimeEventSchema, type RuntimeEvent } from "@/shared/contracts"; +import { + classifyCursorSdkTool, + closeCursorSdkOpenItems, + createCursorSdkMapperState, + mapCursorSdkInteractionUpdate, + mapCursorSdkMessage, + mapCursorSdkRunResult, + startCursorSdkTurn, +} from "./sdkCanonicalMapping"; +import type { + CursorSdkAssistantMessage, + CursorSdkMessage, + CursorSdkToolCallMessage, +} from "./sdkProtocol"; + +const envelope = { agent_id: "agent-1", run_id: "run-1" } as const; + +function assistant( + content: CursorSdkAssistantMessage["message"]["content"], +): CursorSdkAssistantMessage { + return { + type: "assistant", + ...envelope, + message: { role: "assistant", content }, + }; +} + +function toolMessage( + overrides: Omit, +): CursorSdkToolCallMessage { + return { type: "tool_call", ...envelope, ...overrides }; +} + +type ItemStartedEvent = Extract; +type ItemCompletedEvent = Extract; +type ContentDeltaEvent = Extract; + +function started(events: RuntimeEvent[], itemType?: string): ItemStartedEvent[] { + return events.filter( + (event): event is ItemStartedEvent => + event.type === "item.started" && (itemType === undefined || event.itemType === itemType), + ); +} + +function completed(events: RuntimeEvent[]): ItemCompletedEvent[] { + return events.filter((event): event is ItemCompletedEvent => event.type === "item.completed"); +} + +function deltas(events: RuntimeEvent[], stream?: string): ContentDeltaEvent[] { + return events.filter( + (event): event is ContentDeltaEvent => + event.type === "content.delta" && (stream === undefined || event.stream === stream), + ); +} + +function expectCanonical(events: RuntimeEvent[]) { + for (const event of events) { + expect(runtimeEventSchema.safeParse(event).success).toBe(true); + } +} + +describe("Cursor SDK canonical mapping — turn, system, and user lifecycle", () => { + it("starts a turn and suppresses both SDK echoes of an optimistic user item", () => { + const state = createCursorSdkMapperState("thread-1"); + expect(startCursorSdkTurn(state, "turn-1", "optimistic-user")).toEqual([ + { type: "turn.started", threadId: "thread-1", turnId: "turn-1" }, + ]); + + expect( + mapCursorSdkInteractionUpdate( + { + type: "user-message-appended", + userMessage: { + type: "user_message", + session_id: "agent-1", + text: "hello", + }, + }, + state, + ), + ).toEqual([]); + expect( + mapCursorSdkMessage( + { + type: "user", + ...envelope, + message: { role: "user", content: [{ type: "text", text: "hello" }] }, + }, + state, + ), + ).toEqual([]); + }); + + it("maps a user echo once when the session did not paint an optimistic item", () => { + const state = createCursorSdkMapperState("thread-1"); + startCursorSdkTurn(state, "turn-1"); + const raw = mapCursorSdkInteractionUpdate( + { + type: "user-message-appended", + userMessage: { + type: "user_message", + session_id: "agent-1", + text: "hello", + }, + }, + state, + ); + expect(raw).toMatchObject([ + { + type: "item.started", + itemType: "user_message", + payload: { content: [{ kind: "text", text: "hello" }] }, + }, + { type: "item.completed" }, + ]); + expect( + mapCursorSdkMessage( + { + type: "user", + ...envelope, + message: { role: "user", content: [{ type: "text", text: "hello" }] }, + }, + state, + ), + ).toEqual([]); + }); + + it("maps the first system init to session.started and remembers model identity", () => { + const state = createCursorSdkMapperState("thread-1"); + startCursorSdkTurn(state, "turn-1"); + const init: CursorSdkMessage = { + type: "system", + ...envelope, + subtype: "init", + model: { id: "composer-2.5" }, + tools: ["shell"], + }; + expect(mapCursorSdkMessage(init, state)).toEqual([ + { + type: "session.started", + threadId: "thread-1", + turnId: "turn-1", + }, + ]); + expect(mapCursorSdkMessage(init, state)).toEqual([]); + expect(state).toMatchObject({ + agentId: "agent-1", + currentRunId: "run-1", + model: "composer-2.5", + }); + }); +}); + +describe("Cursor SDK canonical mapping — text reconciliation", () => { + it("appends normalized-only assistant chunks to one fallback item until a boundary", () => { + const state = createCursorSdkMapperState("thread-1"); + const events = [ + ...mapCursorSdkMessage(assistant([{ type: "text", text: "Hello " }]), state), + ...mapCursorSdkMessage(assistant([{ type: "text", text: "from Cursor" }]), state), + ...mapCursorSdkMessage( + { + type: "usage", + ...envelope, + usage: { + inputTokens: 1, + outputTokens: 2, + cacheReadTokens: 0, + cacheWriteTokens: 0, + totalTokens: 3, + }, + }, + state, + ), + ]; + expect(events.slice(0, 4)).toMatchObject([ + { type: "item.started", itemType: "assistant_message" }, + { type: "content.delta", stream: "assistant_text", delta: "Hello " }, + { type: "content.delta", stream: "assistant_text", delta: "from Cursor" }, + { type: "item.completed" }, + ]); + expectCanonical(events); + }); + + it("streams raw assistant deltas and consumes each normalized chunk echo", () => { + const state = createCursorSdkMapperState("thread-1"); + const events = [ + ...mapCursorSdkInteractionUpdate({ type: "text-delta", text: "Hel" }, state), + ...mapCursorSdkMessage(assistant([{ type: "text", text: "Hel" }]), state), + ...mapCursorSdkInteractionUpdate({ type: "text-delta", text: "lo" }, state), + ...mapCursorSdkMessage(assistant([{ type: "text", text: "lo" }]), state), + ...mapCursorSdkInteractionUpdate( + { type: "step-completed", stepId: 1, stepDurationMs: 10 }, + state, + ), + ]; + expect(started(events, "assistant_message")).toHaveLength(1); + expect( + deltas(events, "assistant_text").map( + (event) => event.type === "content.delta" && event.delta, + ), + ).toEqual(["Hel", "lo"]); + expect(completed(events)).toHaveLength(1); + }); + + it("consumes normalized batching and splitting without repainting raw content", () => { + const state = createCursorSdkMapperState("thread-1"); + mapCursorSdkInteractionUpdate({ type: "text-delta", text: "Hel" }, state); + mapCursorSdkInteractionUpdate({ type: "text-delta", text: "lo" }, state); + expect(mapCursorSdkMessage(assistant([{ type: "text", text: "H" }]), state)).toEqual([]); + expect(mapCursorSdkMessage(assistant([{ type: "text", text: "ello" }]), state)).toEqual([]); + + mapCursorSdkInteractionUpdate({ type: "text-delta", text: " " }, state); + mapCursorSdkInteractionUpdate({ type: "text-delta", text: "world" }, state); + expect(mapCursorSdkMessage(assistant([{ type: "text", text: " world" }]), state)).toEqual([]); + }); + + it("streams thinking and consumes the SDK's delta and completion echoes", () => { + const state = createCursorSdkMapperState("thread-1"); + const events = [ + ...mapCursorSdkInteractionUpdate({ type: "thinking-delta", text: "Check " }, state), + ...mapCursorSdkMessage({ type: "thinking", ...envelope, text: "Check " }, state), + ...mapCursorSdkInteractionUpdate({ type: "thinking-delta", text: "types" }, state), + ...mapCursorSdkMessage({ type: "thinking", ...envelope, text: "types" }, state), + ...mapCursorSdkInteractionUpdate( + { type: "thinking-completed", thinkingDurationMs: 325 }, + state, + ), + ...mapCursorSdkMessage( + { + type: "thinking", + ...envelope, + text: "", + thinking_duration_ms: 325, + }, + state, + ), + ]; + expect(started(events, "reasoning")).toHaveLength(1); + expect(events).toContainEqual( + expect.objectContaining({ + type: "item.updated", + payload: { summary: "Check types", durationMs: 325 }, + }), + ); + expect(completed(events)).toHaveLength(1); + }); + + it("maps normalized-only thinking with duration", () => { + const state = createCursorSdkMapperState("thread-1"); + const events = [ + ...mapCursorSdkMessage({ type: "thinking", ...envelope, text: "Reason" }, state), + ...mapCursorSdkMessage({ type: "thinking", ...envelope, text: "ing" }, state), + ...mapCursorSdkMessage( + { + type: "thinking", + ...envelope, + text: "", + thinking_duration_ms: 50, + }, + state, + ), + ]; + expect(events).toMatchObject([ + { type: "item.started", itemType: "reasoning" }, + { type: "content.delta", stream: "reasoning_text", delta: "Reason" }, + { type: "content.delta", stream: "reasoning_text", delta: "ing" }, + { type: "item.updated", payload: { summary: "Reasoning", durationMs: 50 } }, + { type: "item.completed" }, + ]); + }); +}); + +describe("Cursor SDK canonical mapping — tool lifecycle and payloads", () => { + it.each([ + ["shell", "command_execution"], + ["bash_command", "command_execution"], + ["write", "file_change"], + ["edit", "file_change"], + ["delete", "file_change"], + ["mcp", "mcp_tool_call"], + ["mcp__github__search", "mcp_tool_call"], + ["webSearch", "web_search"], + ["generateImage", "image_view"], + ["createPlan", "plan"], + ["updateTodos", "plan"], + ["task", "tool_call"], + ["read", "dynamic_tool_call"], + ["glob", "dynamic_tool_call"], + ["grep", "dynamic_tool_call"], + ["ls", "dynamic_tool_call"], + ["readLints", "dynamic_tool_call"], + ["semSearch", "dynamic_tool_call"], + ["recordScreen", "dynamic_tool_call"], + ["futureTool", "dynamic_tool_call"], + ] as const)("classifies %s as %s", (name, expected) => { + expect(classifyCursorSdkTool(name)).toBe(expected); + }); + + it("closes preceding normalized text before an assistant tool_use block", () => { + const state = createCursorSdkMapperState("thread-1"); + const events = mapCursorSdkMessage( + assistant([ + { type: "text", text: "I will inspect it." }, + { + type: "tool_use", + id: "read-mixed", + name: "read", + input: { path: "src/app.ts" }, + }, + ]), + state, + ); + const assistantCompletionIndex = events.findIndex((event) => event.type === "item.completed"); + const toolStartIndex = events.findIndex( + (event) => event.type === "item.started" && event.itemType === "dynamic_tool_call", + ); + expect(assistantCompletionIndex).toBeGreaterThanOrEqual(0); + expect(toolStartIndex).toBeGreaterThan(assistantCompletionIndex); + }); + + it("reconciles raw shell partial/output/completion with normalized lifecycle", () => { + const state = createCursorSdkMapperState("thread-1"); + const start = mapCursorSdkInteractionUpdate( + { + type: "tool-call-started", + callId: "call-shell", + modelCallId: "model-1", + toolCall: { + type: "shell", + args: { command: "pnpm test", workingDirectory: "/repo" }, + }, + }, + state, + ); + expect(start[0]).toMatchObject({ + type: "item.started", + itemType: "command_execution", + payload: { + command: "pnpm test", + cwd: "/repo", + status: "running", + }, + }); + const itemId = start[0]?.type === "item.started" ? start[0].itemId : ""; + + const partial = mapCursorSdkInteractionUpdate( + { + type: "partial-tool-call", + callId: "call-shell", + modelCallId: "model-1", + toolCall: { + type: "shell", + args: { command: "pnpm test --run", workingDirectory: "/repo" }, + }, + }, + state, + ); + expect(started(partial)).toHaveLength(0); + expect(partial).toContainEqual( + expect.objectContaining({ + type: "item.updated", + itemId, + payload: expect.objectContaining({ command: "pnpm test --run" }), + }), + ); + + expect( + mapCursorSdkInteractionUpdate( + { + type: "shell-output-delta", + event: { callId: "call-shell", stdout: "ok" }, + }, + state, + ), + ).toEqual([ + { + type: "content.delta", + threadId: "thread-1", + itemId, + stream: "command_output", + delta: "ok", + }, + ]); + + const done = mapCursorSdkInteractionUpdate( + { + type: "tool-call-completed", + callId: "call-shell", + modelCallId: "model-1", + toolCall: { + type: "shell", + args: { command: "pnpm test --run", workingDirectory: "/repo" }, + result: { + status: "success", + value: { + stdout: "ok\n", + stderr: "", + exitCode: 0, + executionTime: 42, + }, + }, + }, + }, + state, + ); + expect(deltas(done, "command_output")).toMatchObject([{ type: "content.delta", delta: "\n" }]); + expect(done).toContainEqual( + expect.objectContaining({ + type: "item.updated", + itemId, + payload: expect.objectContaining({ + command: "pnpm test --run", + status: "success", + exitCode: 0, + durationMs: 42, + }), + }), + ); + expect(done.at(-1)).toMatchObject({ type: "item.completed", itemId }); + + expect( + mapCursorSdkMessage( + toolMessage({ + call_id: "call-shell", + name: "shell", + status: "completed", + args: { command: "pnpm test --run" }, + result: { stdout: "ok\n", exitCode: 0 }, + }), + state, + ), + ).toEqual([]); + }); + + it("maps normalized-only tool start and completion onto one item", () => { + const state = createCursorSdkMapperState("thread-1"); + const running = mapCursorSdkMessage( + toolMessage({ + call_id: "read-1", + name: "read", + status: "running", + args: { path: "src/app.ts" }, + }), + state, + ); + expect(started(running, "dynamic_tool_call")).toHaveLength(1); + const itemId = started(running)[0]?.itemId ?? ""; + const done = mapCursorSdkMessage( + toolMessage({ + call_id: "read-1", + name: "read", + status: "completed", + args: { path: "src/app.ts" }, + result: { content: "source" }, + }), + state, + ); + expect(started(done)).toHaveLength(0); + expect(done).toContainEqual( + expect.objectContaining({ + type: "item.updated", + itemId, + payload: expect.objectContaining({ status: "success" }), + }), + ); + expect(done.at(-1)).toMatchObject({ type: "item.completed", itemId }); + }); + + it("routes shell output without a call id to the latest open command", () => { + const state = createCursorSdkMapperState("thread-1"); + const running = mapCursorSdkInteractionUpdate( + { + type: "tool-call-started", + callId: "shell-latest", + modelCallId: "model-1", + toolCall: { type: "shell", args: { command: "echo ok" } }, + }, + state, + ); + const itemId = started(running)[0]?.itemId; + expect( + mapCursorSdkInteractionUpdate( + { type: "shell-output-delta", event: { stdout: "ok\n" } }, + state, + ), + ).toEqual([ + { + type: "content.delta", + threadId: "thread-1", + itemId, + stream: "command_output", + delta: "ok\n", + }, + ]); + }); + + it("appends repeated and overlapping shell deltas without snapshot deduplication", () => { + const state = createCursorSdkMapperState("thread-1"); + const running = mapCursorSdkInteractionUpdate( + { + type: "tool-call-started", + callId: "shell-incremental", + modelCallId: "model-1", + toolCall: { type: "shell", args: { command: "stream" } }, + }, + state, + ); + const itemId = started(running)[0]?.itemId; + + const chunks = [".", ".", "foo", "oobar"].flatMap((stdout) => + mapCursorSdkInteractionUpdate( + { + type: "shell-output-delta", + event: { callId: "shell-incremental", stdout }, + }, + state, + ), + ); + + expect(deltas(chunks, "command_output")).toEqual([ + { + type: "content.delta", + threadId: "thread-1", + itemId, + stream: "command_output", + delta: ".", + }, + { + type: "content.delta", + threadId: "thread-1", + itemId, + stream: "command_output", + delta: ".", + }, + { + type: "content.delta", + threadId: "thread-1", + itemId, + stream: "command_output", + delta: "foo", + }, + { + type: "content.delta", + threadId: "thread-1", + itemId, + stream: "command_output", + delta: "oobar", + }, + ]); + }); + + it("does not guess which parallel command owns an uncorrelated shell delta", () => { + const state = createCursorSdkMapperState("thread-1"); + for (const callId of ["shell-one", "shell-two"]) { + mapCursorSdkInteractionUpdate( + { + type: "tool-call-started", + callId, + modelCallId: "model-1", + toolCall: { type: "shell", args: { command: callId } }, + }, + state, + ); + } + + expect( + mapCursorSdkInteractionUpdate( + { type: "shell-output-delta", event: { stdout: "ambiguous" } }, + state, + ), + ).toEqual([]); + + const completedEvents = mapCursorSdkInteractionUpdate( + { + type: "tool-call-completed", + callId: "shell-one", + modelCallId: "model-1", + toolCall: { + type: "shell", + args: { command: "shell-one" }, + result: { status: "success", value: { stdout: "one\n" } }, + }, + }, + state, + ); + expect(deltas(completedEvents, "command_output")).toEqual([ + expect.objectContaining({ delta: "one\n" }), + ]); + }); + + it("reconciles a large rewritten command snapshot without a quadratic scan", () => { + const state = createCursorSdkMapperState("thread-1"); + mapCursorSdkInteractionUpdate( + { + type: "tool-call-started", + callId: "shell-big", + modelCallId: "model-1", + toolCall: { type: "shell", args: { command: "cat big.log" } }, + }, + state, + ); + const streamed = `${"x".repeat(300_000)}shared-tail`; + mapCursorSdkInteractionUpdate( + { type: "shell-output-delta", event: { callId: "shell-big", stdout: streamed } }, + state, + ); + + const startedAt = Date.now(); + const snapshot = `shared-tail${"y".repeat(300_000)}`; + const events = mapCursorSdkInteractionUpdate( + { + type: "tool-call-completed", + callId: "shell-big", + modelCallId: "model-1", + toolCall: { + type: "shell", + args: { command: "cat big.log" }, + result: { status: "success", value: { stdout: snapshot } }, + }, + }, + state, + ); + + expect(deltas(events, "command_output")).toEqual([ + expect.objectContaining({ delta: "y".repeat(300_000) }), + ]); + expect(Date.now() - startedAt).toBeLessThan(1_000); + }); + + it("dedupes assistant tool_use blocks against the tool_call lifecycle", () => { + const state = createCursorSdkMapperState("thread-1"); + const assistantEvents = mapCursorSdkMessage( + assistant([ + { + type: "tool_use", + id: "read-1", + name: "read", + input: { path: "src/app.ts" }, + }, + ]), + state, + ); + expect(started(assistantEvents, "dynamic_tool_call")).toHaveLength(1); + const running = mapCursorSdkMessage( + toolMessage({ + call_id: "read-1", + name: "read", + status: "running", + args: { path: "src/app.ts" }, + }), + state, + ); + expect(started(running)).toHaveLength(0); + }); + + // Diff summaries follow the shared cross-provider normalization: a create + // reports no removals and a delete reports no additions. + it.each([ + ["write", "create", { added: 1, removed: 0 }], + ["edit", "edit", { added: 1, removed: 1 }], + ["delete", "delete", { added: 0, removed: 1 }], + ] as const)("maps %s as a %s file change with diff output", (type, changeKind, diffSummary) => { + const state = createCursorSdkMapperState("thread-1"); + const events = mapCursorSdkInteractionUpdate( + { + type: "tool-call-completed", + callId: `file-${type}`, + modelCallId: "model-1", + toolCall: { + type, + args: { path: "src/file.ts" }, + result: { + status: "success", + value: { diffString: "@@ -1 +1 @@\n-old\n+new" }, + }, + }, + }, + state, + ); + expect(started(events, "file_change")[0]).toMatchObject({ + payload: { + path: "src/file.ts", + changeKind, + }, + }); + expect(deltas(events, "file_change_output")).toMatchObject([ + { delta: "@@ -1 +1 @@\n-old\n+new" }, + ]); + expect(events).toContainEqual( + expect.objectContaining({ + type: "item.updated", + payload: expect.objectContaining({ + status: "success", + diffSummary, + }), + }), + ); + }); + + it("normalizes updateTodos and createPlan into canonical plan steps", () => { + const todos = createCursorSdkMapperState("thread-1"); + const todoEvents = mapCursorSdkInteractionUpdate( + { + type: "tool-call-started", + callId: "todos", + modelCallId: "model-1", + toolCall: { + type: "updateTodos", + args: { + todos: [ + { content: "Inspect", status: "completed" }, + { content: "Implement", status: "inProgress" }, + { content: "Verify", status: "pending" }, + ], + }, + }, + }, + todos, + ); + expect(started(todoEvents, "plan")[0]).toMatchObject({ + payload: { + steps: [ + { step: "Inspect", status: "completed" }, + { step: "Implement", status: "in_progress" }, + { step: "Verify", status: "pending" }, + ], + }, + }); + + const plan = createCursorSdkMapperState("thread-2"); + const planEvents = mapCursorSdkInteractionUpdate( + { + type: "tool-call-started", + callId: "plan", + modelCallId: "model-1", + toolCall: { + type: "createPlan", + args: { plan: "# Plan\n- [x] Read\n- [ ] Build" }, + }, + }, + plan, + ); + expect(started(planEvents, "plan")[0]).toMatchObject({ + payload: { + steps: [ + { step: "Plan", status: "pending" }, + { step: "Read", status: "completed" }, + { step: "Build", status: "pending" }, + ], + }, + }); + }); + + it("normalizes MCP identity, arguments, and inline result images", () => { + const state = createCursorSdkMapperState("thread-1"); + const events = mapCursorSdkInteractionUpdate( + { + type: "tool-call-completed", + callId: "mcp-1", + modelCallId: "model-1", + toolCall: { + type: "mcp", + args: { + providerIdentifier: "images", + toolName: "render", + args: { prompt: "owl" }, + }, + result: { + status: "success", + value: { + content: [{ image: { data: "YWJj", mimeType: "image/webp" } }], + isError: false, + }, + }, + }, + }, + state, + ); + expect(started(events, "mcp_tool_call")[0]).toMatchObject({ + payload: { + name: "render", + serverId: "images", + args: { prompt: "owl" }, + status: "running", + }, + }); + expect(events).toContainEqual( + expect.objectContaining({ + type: "item.updated", + payload: expect.objectContaining({ + status: "success", + images: ["data:image/webp;base64,YWJj"], + }), + }), + ); + }); + + it("maps generateImage output to an inline image_view data URL", () => { + const state = createCursorSdkMapperState("thread-1"); + const events = mapCursorSdkInteractionUpdate( + { + type: "tool-call-completed", + callId: "image-1", + modelCallId: "model-1", + toolCall: { + type: "generateImage", + args: { description: "owl" }, + result: { + status: "success", + value: { filePath: "owl.png", imageData: "YWJj" }, + }, + }, + }, + state, + ); + expect(started(events, "image_view")).toHaveLength(1); + expect(events).toContainEqual( + expect.objectContaining({ + type: "item.updated", + payload: expect.objectContaining({ + images: ["data:image/png;base64,YWJj"], + status: "success", + }), + }), + ); + }); + + it("surfaces tool errors as completed error payloads", () => { + const state = createCursorSdkMapperState("thread-1"); + const events = mapCursorSdkInteractionUpdate( + { + type: "tool-call-completed", + callId: "shell-error", + modelCallId: "model-1", + toolCall: { + type: "shell", + args: { command: "false" }, + result: { status: "error", error: { message: "exit 1" } }, + }, + }, + state, + ); + expect(events).toContainEqual( + expect.objectContaining({ + type: "item.updated", + payload: expect.objectContaining({ status: "error", errorMessage: "exit 1" }), + }), + ); + expect(events.at(-1)).toMatchObject({ + type: "item.completed", + payload: expect.objectContaining({ status: "error" }), + }); + }); + + it("infers a normalized completion error from its wrapped result", () => { + const state = createCursorSdkMapperState("thread-1"); + const events = mapCursorSdkMessage( + toolMessage({ + call_id: "normalized-error", + name: "shell", + status: "completed", + args: { command: "false" }, + result: { status: "error", error: { message: "exit 1" } }, + }), + state, + ); + expect(events).toContainEqual( + expect.objectContaining({ + type: "item.updated", + payload: expect.objectContaining({ status: "error", errorMessage: "exit 1" }), + }), + ); + expect(events.at(-1)).toMatchObject({ + type: "item.completed", + payload: expect.objectContaining({ status: "error" }), + }); + }); + + it("treats an MCP success envelope with isError content as a tool error", () => { + const state = createCursorSdkMapperState("thread-1"); + const events = mapCursorSdkInteractionUpdate( + { + type: "tool-call-completed", + callId: "mcp-content-error", + modelCallId: "model-1", + toolCall: { + type: "mcp", + args: { providerIdentifier: "github", toolName: "search", args: {} }, + result: { + status: "success", + value: { content: [{ text: { text: "failed" } }], isError: true }, + }, + }, + }, + state, + ); + expect(events.at(-1)).toMatchObject({ + type: "item.completed", + payload: expect.objectContaining({ status: "error" }), + }); + }); +}); + +describe("Cursor SDK canonical mapping — nested task updates", () => { + it("groups nested text, thinking, and tools under one task parent", () => { + const state = createCursorSdkMapperState("thread-1"); + const parent = mapCursorSdkInteractionUpdate( + { + type: "tool-call-started", + callId: "task-1", + modelCallId: "model-parent", + toolCall: { + type: "task", + args: { description: "Audit code", model: "composer-2.5" }, + }, + }, + state, + ); + const parentStarted = started(parent, "tool_call")[0]; + expect(parentStarted).toMatchObject({ + payload: { + name: "task", + isSubAgent: true, + progress: { description: "Audit code", model: "composer-2.5" }, + }, + }); + const parentItemId = parentStarted?.type === "item.started" ? parentStarted.itemId : ""; + + const text = mapCursorSdkInteractionUpdate( + { + type: "tool-call-delta", + callId: "task-1", + modelCallId: "model-parent", + taskUpdate: { type: "text-delta", text: "Found issue" }, + }, + state, + ); + expect(started(text, "assistant_message")[0]).toMatchObject({ parentItemId }); + expect(text).toContainEqual( + expect.objectContaining({ + type: "item.updated", + itemId: parentItemId, + payload: expect.objectContaining({ + progress: expect.objectContaining({ description: "Found issue" }), + }), + }), + ); + + const thinking = mapCursorSdkInteractionUpdate( + { + type: "tool-call-delta", + callId: "task-1", + modelCallId: "model-parent", + taskUpdate: { type: "thinking-delta", text: "Investigating" }, + }, + state, + ); + expect(started(thinking, "reasoning")[0]).toMatchObject({ parentItemId }); + + const childTool = mapCursorSdkInteractionUpdate( + { + type: "tool-call-delta", + callId: "task-1", + modelCallId: "model-parent", + taskUpdate: { + type: "tool-call-started", + callId: "read-child", + modelCallId: "model-child", + toolCall: { type: "read", args: { path: "src/app.ts" } }, + }, + }, + state, + ); + expect(started(childTool, "dynamic_tool_call")[0]).toMatchObject({ parentItemId }); + expect(childTool).toContainEqual( + expect.objectContaining({ + type: "item.updated", + itemId: parentItemId, + payload: expect.objectContaining({ + progress: expect.objectContaining({ lastToolName: "read", stepCount: 1 }), + }), + }), + ); + + mapCursorSdkInteractionUpdate( + { + type: "tool-call-delta", + callId: "task-1", + modelCallId: "model-parent", + taskUpdate: { + type: "partial-tool-call", + callId: "read-child", + modelCallId: "model-child", + toolCall: { type: "read", args: { path: "src/app.ts", offset: 10 } }, + }, + }, + state, + ); + expect(state.toolItems.get("task-1")?.progress?.stepCount).toBe(1); + + const stepDone = mapCursorSdkInteractionUpdate( + { + type: "tool-call-delta", + callId: "task-1", + modelCallId: "model-parent", + taskUpdate: { type: "step-completed", stepId: 1, stepDurationMs: 25 }, + }, + state, + ); + expect(completed(stepDone)).toHaveLength(2); + + const parentDone = mapCursorSdkInteractionUpdate( + { + type: "tool-call-completed", + callId: "task-1", + modelCallId: "model-parent", + toolCall: { + type: "task", + args: { description: "Audit code", model: "composer-2.5" }, + result: { + status: "success", + value: { durationMs: 100, resultSuffix: "Done" }, + }, + }, + }, + state, + ); + // The still-open child tool is closed before its parent. + expect(completed(parentDone).length).toBeGreaterThanOrEqual(2); + expect(parentDone).toContainEqual( + expect.objectContaining({ + type: "item.updated", + itemId: parentItemId, + payload: expect.objectContaining({ + progress: expect.objectContaining({ summary: "Done", durationMs: 100 }), + }), + }), + ); + expect(parentDone.at(-1)).toMatchObject({ type: "item.completed", itemId: parentItemId }); + }); + + it("synthesizes a task parent when a nested delta arrives before tool start", () => { + const state = createCursorSdkMapperState("thread-1"); + const events = mapCursorSdkInteractionUpdate( + { + type: "tool-call-delta", + callId: "task-late", + modelCallId: "model-parent", + taskUpdate: { type: "text-delta", text: "Working" }, + }, + state, + ); + const parent = started(events, "tool_call")[0]; + expect(parent).toMatchObject({ + payload: expect.objectContaining({ isSubAgent: true, status: "running" }), + }); + const parentItemId = parent?.type === "item.started" ? parent.itemId : ""; + expect(started(events, "assistant_message")[0]).toMatchObject({ parentItemId }); + }); + + it("ignores a late nested delta after its parent task completed", () => { + const state = createCursorSdkMapperState("thread-1"); + mapCursorSdkInteractionUpdate( + { + type: "tool-call-completed", + callId: "task-finished", + modelCallId: "model-parent", + toolCall: { + type: "task", + args: { description: "Done" }, + result: { status: "success", value: { resultSuffix: "Done", isBackground: false } }, + }, + }, + state, + ); + expect( + mapCursorSdkInteractionUpdate( + { + type: "tool-call-delta", + callId: "task-finished", + modelCallId: "model-parent", + taskUpdate: { type: "text-delta", text: "late" }, + }, + state, + ), + ).toEqual([]); + }); +}); + +describe("Cursor SDK canonical mapping — auxiliary updates and usage", () => { + it("maps summary snapshots into one reasoning item", () => { + const state = createCursorSdkMapperState("thread-1"); + const events = [ + ...mapCursorSdkInteractionUpdate({ type: "summary-started" }, state), + ...mapCursorSdkInteractionUpdate({ type: "summary", summary: "First" }, state), + ...mapCursorSdkMessage({ type: "task", ...envelope, text: "First" }, state), + ...mapCursorSdkInteractionUpdate({ type: "summary", summary: "First" }, state), + ...mapCursorSdkMessage({ type: "task", ...envelope, text: "First" }, state), + ...mapCursorSdkInteractionUpdate({ type: "summary", summary: "Final" }, state), + ...mapCursorSdkMessage({ type: "task", ...envelope, text: "Final" }, state), + ...mapCursorSdkInteractionUpdate({ type: "summary-completed" }, state), + ]; + expect(started(events, "reasoning")).toHaveLength(1); + expect(events.filter((event) => event.type === "item.updated")).toMatchObject([ + { payload: { summary: "First" } }, + { payload: { summary: "Final" } }, + ]); + expect(completed(events)).toHaveLength(1); + }); + + it("maps task milestones into one reasoning item and closes terminal status", () => { + const state = createCursorSdkMapperState("thread-1"); + const first = mapCursorSdkMessage( + { type: "task", ...envelope, status: "running", text: "Reviewing" }, + state, + ); + const done = mapCursorSdkMessage( + { type: "task", ...envelope, status: "completed", text: "Reviewed" }, + state, + ); + expect(started(first, "reasoning")).toHaveLength(1); + expect(done).toMatchObject([ + { type: "item.updated", payload: { summary: "Reviewed" } }, + { type: "item.completed" }, + ]); + }); + + it("dedupes repeated statusless task summaries from the normalized fallback", () => { + const state = createCursorSdkMapperState("thread-1"); + const first = mapCursorSdkMessage( + { type: "task", ...envelope, text: "Compacting context" }, + state, + ); + expect(started(first, "reasoning")).toHaveLength(1); + expect( + mapCursorSdkMessage({ type: "task", ...envelope, text: "Compacting context" }, state), + ).toEqual([]); + }); + + it("deliberately ignores SDK request events because no response API exists", () => { + const state = createCursorSdkMapperState("thread-1"); + const events = mapCursorSdkMessage( + { type: "request", ...envelope, request_id: "request-1" }, + state, + ); + expect(events).toEqual([]); + }); + + it("ignores token-delta because final usage is authoritative", () => { + const state = createCursorSdkMapperState("thread-1"); + expect(mapCursorSdkInteractionUpdate({ type: "token-delta", tokens: 32 }, state)).toEqual([]); + }); + + it("maps raw turn usage to spend without claiming context-window occupancy", () => { + const state = createCursorSdkMapperState("thread-1"); + startCursorSdkTurn(state, "turn-1"); + state.agentId = "agent-1"; + state.currentRunId = "run-1"; + state.model = "composer-2.5"; + const events = mapCursorSdkInteractionUpdate( + { + type: "turn-ended", + usage: { + inputTokens: 100, + outputTokens: 25, + cacheReadTokens: 10, + cacheWriteTokens: 5, + reasoningTokens: 7, + }, + }, + state, + ); + expect(events).toMatchObject([ + { + type: "usage.spent", + usage: { + counterKind: "per-call", + counter: 140, + scopeId: "agent-1", + sampleId: "run-1:turn-1", + turnId: "turn-1", + model: "composer-2.5", + }, + }, + ]); + expectCanonical(events); + }); + + it("dedupes the normalized usage message that follows an identical raw update", () => { + const state = createCursorSdkMapperState("thread-1"); + const usage = { + inputTokens: 10, + outputTokens: 3, + cacheReadTokens: 2, + cacheWriteTokens: 1, + reasoningTokens: 1, + }; + expect(mapCursorSdkInteractionUpdate({ type: "turn-ended", usage }, state)).toHaveLength(1); + expect( + mapCursorSdkMessage( + { type: "usage", ...envelope, usage: { ...usage, totalTokens: 16 } }, + state, + ), + ).toEqual([]); + }); + + it("dedupes the raw usage update that follows an identical normalized message", () => { + const state = createCursorSdkMapperState("thread-1"); + const usage = { + inputTokens: 10, + outputTokens: 3, + cacheReadTokens: 2, + cacheWriteTokens: 1, + reasoningTokens: 1, + }; + expect( + mapCursorSdkMessage( + { type: "usage", ...envelope, usage: { ...usage, totalTokens: 16 } }, + state, + ), + ).toHaveLength(1); + expect(mapCursorSdkInteractionUpdate({ type: "turn-ended", usage }, state)).toEqual([]); + }); + + it("maps distinct normalized per-turn usage samples independently", () => { + const state = createCursorSdkMapperState("thread-1"); + const first = mapCursorSdkMessage( + { + type: "usage", + ...envelope, + usage: { + inputTokens: 10, + outputTokens: 2, + cacheReadTokens: 0, + cacheWriteTokens: 0, + totalTokens: 12, + }, + }, + state, + ); + const second = mapCursorSdkMessage( + { + type: "usage", + ...envelope, + usage: { + inputTokens: 20, + outputTokens: 4, + cacheReadTokens: 0, + cacheWriteTokens: 0, + totalTokens: 24, + }, + }, + state, + ); + expect(first.find((event) => event.type === "usage.spent")).toMatchObject({ + usage: { counter: 12, sampleId: "run-1:turn-1" }, + }); + expect(second.find((event) => event.type === "usage.spent")).toMatchObject({ + usage: { counter: 24, sampleId: "run-1:turn-2" }, + }); + }); + + it("uses cumulative RunResult usage only as a no-stream fallback", () => { + const state = createCursorSdkMapperState("thread-1"); + startCursorSdkTurn(state, "turn-1"); + const events = mapCursorSdkRunResult( + { + id: "run-fallback", + status: "finished", + model: { id: "composer-2.5" }, + usage: { + inputTokens: 50, + outputTokens: 10, + cacheReadTokens: 5, + cacheWriteTokens: 0, + totalTokens: 65, + }, + }, + state, + ); + expect(events.find((event) => event.type === "usage.spent")).toMatchObject({ + usage: { + counter: 65, + sampleId: "run-fallback:turn-1", + model: "composer-2.5", + }, + }); + expect(events.some((event) => event.type === "context.updated")).toBe(false); + expect(events.at(-1)).toEqual({ + type: "turn.completed", + threadId: "thread-1", + turnId: "turn-1", + state: "completed", + }); + + const streamed = createCursorSdkMapperState("thread-2"); + mapCursorSdkMessage( + { + type: "usage", + ...envelope, + usage: { + inputTokens: 5, + outputTokens: 1, + cacheReadTokens: 0, + cacheWriteTokens: 0, + totalTokens: 6, + }, + }, + streamed, + ); + const result = mapCursorSdkRunResult( + { + id: "run-1", + status: "finished", + usage: { + inputTokens: 50, + outputTokens: 10, + cacheReadTokens: 5, + cacheWriteTokens: 0, + totalTokens: 65, + }, + }, + streamed, + ); + expect(result.filter((event) => event.type === "usage.spent")).toEqual([]); + }); + + it("resets result-usage fallback accounting for each Poracode turn", () => { + const state = createCursorSdkMapperState("thread-1"); + startCursorSdkTurn(state, "turn-1"); + const first = mapCursorSdkRunResult( + { + id: "run-1", + status: "finished", + usage: { + inputTokens: 4, + outputTokens: 1, + cacheReadTokens: 0, + cacheWriteTokens: 0, + totalTokens: 5, + }, + }, + state, + ); + expect(first.filter((event) => event.type === "usage.spent")).toHaveLength(1); + + startCursorSdkTurn(state, "turn-2"); + const second = mapCursorSdkRunResult( + { + id: "run-2", + status: "finished", + usage: { + inputTokens: 8, + outputTokens: 2, + cacheReadTokens: 0, + cacheWriteTokens: 0, + totalTokens: 10, + }, + }, + state, + ); + expect(second.find((event) => event.type === "usage.spent")).toMatchObject({ + usage: { counter: 10, sampleId: "run-2:turn-1", turnId: "turn-2" }, + }); + }); +}); + +describe("Cursor SDK canonical mapping — terminal outcomes and cleanup", () => { + it("uses the RunResult text only when no assistant stream output arrived", () => { + const fallback = createCursorSdkMapperState("thread-1"); + startCursorSdkTurn(fallback, "turn-1"); + const fallbackEvents = mapCursorSdkRunResult( + { id: "run-1", status: "finished", result: "fallback answer" }, + fallback, + ); + expect(deltas(fallbackEvents, "assistant_text")).toEqual([ + expect.objectContaining({ delta: "fallback answer" }), + ]); + expect(fallbackEvents.at(-1)).toMatchObject({ + type: "turn.completed", + state: "completed", + }); + + const streamed = createCursorSdkMapperState("thread-2"); + startCursorSdkTurn(streamed, "turn-2"); + mapCursorSdkInteractionUpdate({ type: "text-delta", text: "streamed answer" }, streamed); + const resultEvents = mapCursorSdkRunResult( + { id: "run-2", status: "finished", result: "streamed answer" }, + streamed, + ); + expect(deltas(resultEvents, "assistant_text")).toEqual([]); + }); + + it("maps a successful status and ignores the duplicate RunResult terminal", () => { + const state = createCursorSdkMapperState("thread-1"); + startCursorSdkTurn(state, "turn-1"); + expect(mapCursorSdkMessage({ type: "status", ...envelope, status: "RUNNING" }, state)).toEqual( + [], + ); + expect(mapCursorSdkMessage({ type: "status", ...envelope, status: "FINISHED" }, state)).toEqual( + [ + { + type: "turn.completed", + threadId: "thread-1", + turnId: "turn-1", + state: "completed", + }, + ], + ); + expect(mapCursorSdkRunResult({ id: "run-1", status: "finished" }, state)).toEqual([]); + }); + + it("maps errors and expired runs to failed exactly once", () => { + const state = createCursorSdkMapperState("thread-1"); + startCursorSdkTurn(state, "turn-1"); + const events = mapCursorSdkMessage( + { type: "status", ...envelope, status: "ERROR", message: "API failed" }, + state, + ); + expect(events).toEqual([ + { type: "error", threadId: "thread-1", message: "API failed" }, + { + type: "turn.completed", + threadId: "thread-1", + turnId: "turn-1", + state: "failed", + }, + ]); + expect( + mapCursorSdkRunResult( + { id: "run-1", status: "error", error: { message: "API failed" } }, + state, + ), + ).toEqual([]); + + const expired = createCursorSdkMapperState("thread-2"); + expect( + mapCursorSdkMessage( + { + type: "status", + agent_id: "agent-2", + run_id: "run-expired", + status: "EXPIRED", + }, + expired, + ), + ).toMatchObject([ + { type: "error", message: "EXPIRED" }, + { type: "turn.completed", turnId: "run-expired", state: "failed" }, + ]); + }); + + it("allows the same failure message to be reported again on a later turn", () => { + const state = createCursorSdkMapperState("thread-1"); + startCursorSdkTurn(state, "turn-1"); + expect( + mapCursorSdkMessage( + { type: "status", ...envelope, status: "ERROR", message: "API failed" }, + state, + ).filter((event) => event.type === "error"), + ).toHaveLength(1); + + startCursorSdkTurn(state, "turn-2"); + expect( + mapCursorSdkMessage( + { + type: "status", + agent_id: "agent-1", + run_id: "run-2", + status: "ERROR", + message: "API failed", + }, + state, + ).filter((event) => event.type === "error"), + ).toHaveLength(1); + }); + + it("maps cancellation and closes every open item before turn completion", () => { + const state = createCursorSdkMapperState("thread-1"); + startCursorSdkTurn(state, "turn-1"); + const beforeTerminal = [ + ...mapCursorSdkInteractionUpdate({ type: "text-delta", text: "partial" }, state), + ...mapCursorSdkInteractionUpdate( + { + type: "tool-call-started", + callId: "read-1", + modelCallId: "model-1", + toolCall: { type: "read", args: { path: "src/app.ts" } }, + }, + state, + ), + ]; + const events = mapCursorSdkRunResult({ id: "run-1", status: "cancelled" }, state); + expect(completed([...beforeTerminal, ...events])).toHaveLength(2); + expect(completed(events)).toHaveLength(1); + expect(events.at(-1)).toEqual({ + type: "turn.completed", + threadId: "thread-1", + turnId: "turn-1", + state: "cancelled", + }); + }); + + it("closes open items idempotently during disposal", () => { + const state = createCursorSdkMapperState("thread-1"); + mapCursorSdkInteractionUpdate({ type: "text-delta", text: "partial" }, state); + mapCursorSdkInteractionUpdate({ type: "thinking-delta", text: "reason" }, state); + mapCursorSdkInteractionUpdate({ type: "summary-started" }, state); + mapCursorSdkMessage({ type: "task", ...envelope, status: "running", text: "Working" }, state); + mapCursorSdkInteractionUpdate( + { + type: "tool-call-started", + callId: "read-1", + modelCallId: "model-1", + toolCall: { type: "read", args: { path: "src/app.ts" } }, + }, + state, + ); + const close = closeCursorSdkOpenItems(state); + expect(completed(close)).toHaveLength(3); + expect(closeCursorSdkOpenItems(state)).toEqual([]); + expectCanonical(close); + }); + + it("closes nested tools before their parent and blocks late normalized reopens", () => { + const state = createCursorSdkMapperState("thread-1"); + const parentEvents = mapCursorSdkInteractionUpdate( + { + type: "tool-call-started", + callId: "task-open", + modelCallId: "model-parent", + toolCall: { type: "task", args: { description: "Inspect" } }, + }, + state, + ); + const parentItemId = started(parentEvents, "tool_call")[0]?.itemId; + const childEvents = mapCursorSdkInteractionUpdate( + { + type: "tool-call-delta", + callId: "task-open", + modelCallId: "model-parent", + taskUpdate: { + type: "tool-call-started", + callId: "read-open", + modelCallId: "model-child", + toolCall: { type: "read", args: { path: "src/app.ts" } }, + }, + }, + state, + ); + const childItemId = started(childEvents, "dynamic_tool_call")[0]?.itemId; + const close = closeCursorSdkOpenItems(state); + expect(completed(close).map((event) => event.itemId)).toEqual([childItemId, parentItemId]); + expect( + mapCursorSdkMessage( + toolMessage({ + call_id: "task-open", + name: "task", + status: "completed", + result: { status: "success", value: {} }, + }), + state, + ), + ).toEqual([]); + }); +}); diff --git a/src/supervisor/agents/cursor/sdkCanonicalMapping.ts b/src/supervisor/agents/cursor/sdkCanonicalMapping.ts new file mode 100644 index 000000000..bf568f104 --- /dev/null +++ b/src/supervisor/agents/cursor/sdkCanonicalMapping.ts @@ -0,0 +1,552 @@ +/** + * Cursor SDK → canonical RuntimeEvent mapper. + * + * Cursor exposes the same interaction through two streams: + * + * - `onDelta` provides live token/tool updates. + * - `run.stream()` provides normalized, durable SDKMessage events. + * + * Raw deltas are authoritative when both are connected. In SDK 1.0.24, each + * normalized assistant/thinking message echoes one raw update; FIFO + * reconciliation keeps that second delivery from repainting the same content. + * The normalized stream also works alone as a stable fallback. + */ + +import type { RuntimeEvent } from "@/shared/contracts"; +import { newItemId } from "../contextUsage"; +import type { CursorSdkMapperState } from "./sdkCanonicalMappingState"; +import { + closeCursorSdkToolItems, + mapCursorSdkAssistantToolUse, + mapCursorSdkNestedTaskUpdate, + mapCursorSdkNormalizedToolCall, + mapCursorSdkRawToolUpdate, + mapCursorSdkShellOutputDelta, +} from "./sdkCanonicalToolMapping"; +import { normalizeCursorSdkUsage } from "./sdkCanonicalToolPayload"; +import type { + CursorSdkAssistantMessage, + CursorSdkInteractionUpdate, + CursorSdkMessage, + CursorSdkRunResult, + CursorSdkTokenUsage, +} from "./sdkProtocol"; + +export { createCursorSdkMapperState, type CursorSdkMapperState } from "./sdkCanonicalMappingState"; +export { classifyCursorSdkTool } from "./sdkCanonicalToolMapping"; + +/** + * Establish a Poracode user turn before `agent.send()`. + * + * The session owns the optimistic user bubble. Supplying its id tells the + * mapper that raw/normalized Cursor echoes are acknowledgements, not new chat + * items. + */ +export function startCursorSdkTurn( + state: CursorSdkMapperState, + turnId: string, + optimisticUserItemId?: string, +): RuntimeEvent[] { + const events = closeCursorSdkOpenItems(state); + state.currentTurnId = turnId; + delete state.currentRunId; + delete state.model; + state.userEchoSeen = false; + state.assistantOutputSeen = false; + if (optimisticUserItemId) { + state.optimisticUserItemId = optimisticUserItemId; + } else { + delete state.optimisticUserItemId; + } + state.completedToolKeys.clear(); + state.pendingRawAssistantDeltas.length = 0; + state.pendingRawThinkingDeltas.length = 0; + state.pendingRawThinkingCompletions.length = 0; + state.pendingRawTaskTexts.length = 0; + state.pendingRawUsageFingerprints.length = 0; + state.pendingNormalizedUsageFingerprints.length = 0; + state.usageSequence = 0; + state.emittedErrors.clear(); + events.push({ type: "turn.started", threadId: state.threadId, turnId }); + return events; +} + +export function mapCursorSdkMessage( + message: CursorSdkMessage, + state: CursorSdkMapperState, +): RuntimeEvent[] { + state.agentId = message.agent_id; + state.currentRunId = message.run_id; + + switch (message.type) { + case "system": { + if (message.model?.id) state.model = message.model.id; + if (state.sessionStarted) return []; + state.sessionStarted = true; + return [ + { + type: "session.started", + threadId: state.threadId, + ...(state.currentTurnId ? { turnId: state.currentTurnId } : {}), + }, + ]; + } + case "user": + return mapUserEcho(message.message.content.map((block) => block.text).join(""), state); + case "assistant": + return mapAssistantMessage(message, state); + case "thinking": + return mapNormalizedThinking(state, message.text, message.thinking_duration_ms); + case "tool_call": { + const events = closeTopLevelTextForStep(state); + events.push(...mapCursorSdkNormalizedToolCall(message, state)); + return events; + } + case "status": + if (message.status === "FINISHED") { + return finishCursorSdkRun(state, message.run_id, "completed"); + } + if (message.status === "CANCELLED") { + return finishCursorSdkRun(state, message.run_id, "cancelled"); + } + if (message.status === "ERROR" || message.status === "EXPIRED") { + return finishCursorSdkRun( + state, + message.run_id, + "failed", + message.message ?? message.status, + ); + } + return []; + case "task": + if (message.text && consumeQueuedText(state.pendingRawTaskTexts, message.text)) return []; + return mapTaskMessage(state, message.status, message.text); + case "request": + // @cursor/sdk 1.0.24 exposes neither request details nor a response + // method. Opening a canonical request would strand the UI forever. + return []; + case "usage": { + const events = closeTopLevelTextForStep(state); + events.push(...mapUsage(state, message.usage, "normalized")); + return events; + } + } +} + +export function mapCursorSdkInteractionUpdate( + update: CursorSdkInteractionUpdate, + state: CursorSdkMapperState, +): RuntimeEvent[] { + switch (update.type) { + case "text-delta": + return appendTopLevelTextDelta(state, "assistant", update.text); + case "thinking-delta": + return appendTopLevelTextDelta(state, "thinking", update.text); + case "thinking-completed": + return closeTopLevelThinking(state, update.thinkingDurationMs, true); + case "tool-call-started": + case "partial-tool-call": { + const events = closeTopLevelTextForStep(state); + events.push(...mapCursorSdkRawToolUpdate(state, update.callId, update.toolCall, false)); + return events; + } + case "tool-call-completed": + return mapCursorSdkRawToolUpdate(state, update.callId, update.toolCall, true); + case "tool-call-delta": + return mapCursorSdkNestedTaskUpdate(state, update.callId, update.taskUpdate); + case "token-delta": + // Token deltas are generation progress, not final usage and not + // context-window occupancy. `turn-ended`/`usage` are authoritative. + return []; + case "step-started": + case "step-completed": + return closeTopLevelTextForStep(state); + case "turn-ended": { + const events = closeTopLevelTextForStep(state); + if (update.usage) events.push(...mapUsage(state, update.usage, "raw")); + return events; + } + case "user-message-appended": + return mapUserEcho(update.userMessage.text, state); + case "summary-started": + return ensureSummaryItem(state); + case "summary": + state.pendingRawTaskTexts.push(update.summary); + return updateSummaryItem(state, update.summary); + case "summary-completed": + return completeSummaryItem(state); + case "shell-output-delta": + return mapCursorSdkShellOutputDelta(state, update.event); + } +} + +export function mapCursorSdkRunResult( + result: CursorSdkRunResult, + state: CursorSdkMapperState, +): RuntimeEvent[] { + state.currentRunId = result.id; + if (result.model?.id) state.model = result.model.id; + const events: RuntimeEvent[] = []; + if ( + result.status === "finished" && + result.result && + !state.assistantOutputSeen && + !state.terminalRunIds.has(result.id) + ) { + events.push(...appendTopLevelTextDelta(state, "assistant", result.result, false)); + closeTextItem(state, "assistant", events); + } + // `RunResult.usage` is cumulative across its SDK turns. Only use it when no + // per-turn usage event reached us, otherwise it would double-count spend. + if (result.usage && state.usageSequence === 0) { + events.push(...mapUsage(state, result.usage, "normalized")); + } + if (result.status === "finished") { + events.push(...finishCursorSdkRun(state, result.id, "completed")); + } else if (result.status === "cancelled") { + events.push(...finishCursorSdkRun(state, result.id, "cancelled")); + } else { + events.push( + ...finishCursorSdkRun( + state, + result.id, + "failed", + result.error?.message ?? result.error?.code ?? result.status, + ), + ); + } + return events; +} + +/** + * Complete all mapper-owned items. Safe to call repeatedly during disposal, + * rollback, or before a new turn. + */ +export function closeCursorSdkOpenItems(state: CursorSdkMapperState): RuntimeEvent[] { + const events: RuntimeEvent[] = []; + closeTextItem(state, "assistant", events); + closeTextItem(state, "thinking", events); + closeTextItem(state, "summary", events); + closeTextItem(state, "task", events); + events.push(...closeCursorSdkToolItems(state)); + return events; +} + +function mapAssistantMessage( + message: CursorSdkAssistantMessage, + state: CursorSdkMapperState, +): RuntimeEvent[] { + const events: RuntimeEvent[] = []; + for (const block of message.message.content) { + if (block.type === "text") { + if ( + block.text.length > 0 && + !consumeQueuedText(state.pendingRawAssistantDeltas, block.text) + ) { + events.push(...appendTopLevelTextDelta(state, "assistant", block.text, false)); + } + } else { + events.push(...closeTopLevelTextForStep(state)); + events.push(...mapCursorSdkAssistantToolUse(state, block.id, block.name, block.input)); + } + } + return events; +} + +function mapNormalizedThinking( + state: CursorSdkMapperState, + text: string, + durationMs?: number, +): RuntimeEvent[] { + const events: RuntimeEvent[] = []; + if (text.length > 0 && !consumeQueuedText(state.pendingRawThinkingDeltas, text)) { + events.push(...appendTopLevelTextDelta(state, "thinking", text, false)); + } + if (durationMs !== undefined) { + const rawCompletionIndex = state.pendingRawThinkingCompletions.indexOf(durationMs); + if (rawCompletionIndex >= 0) { + state.pendingRawThinkingCompletions.splice(rawCompletionIndex, 1); + } else { + events.push(...closeTopLevelThinking(state, durationMs, false)); + } + } + return events; +} + +function appendTopLevelTextDelta( + state: CursorSdkMapperState, + kind: "assistant" | "thinking", + delta: string, + recordRawEcho = true, +): RuntimeEvent[] { + if (delta.length === 0) return []; + if (recordRawEcho) { + const pending = + kind === "assistant" ? state.pendingRawAssistantDeltas : state.pendingRawThinkingDeltas; + pending.push(delta); + } + if (kind === "assistant") state.assistantOutputSeen = true; + const events: RuntimeEvent[] = []; + let item = kind === "assistant" ? state.assistantItem : state.thinkingItem; + if (!item) { + item = { itemId: newItemId(kind === "assistant" ? "asst" : "reason"), text: "" }; + if (kind === "assistant") state.assistantItem = item; + else state.thinkingItem = item; + events.push({ + type: "item.started", + threadId: state.threadId, + itemId: item.itemId, + itemType: kind === "assistant" ? "assistant_message" : "reasoning", + }); + } + item.text += delta; + events.push({ + type: "content.delta", + threadId: state.threadId, + itemId: item.itemId, + stream: kind === "assistant" ? "assistant_text" : "reasoning_text", + delta, + }); + return events; +} + +function closeTopLevelThinking( + state: CursorSdkMapperState, + durationMs: number, + recordRawEcho: boolean, +): RuntimeEvent[] { + const events: RuntimeEvent[] = []; + if (recordRawEcho) state.pendingRawThinkingCompletions.push(durationMs); + const item = state.thinkingItem; + if (item) { + events.push({ + type: "item.updated", + threadId: state.threadId, + itemId: item.itemId, + payload: { summary: item.text, durationMs }, + }); + } + closeTextItem(state, "thinking", events); + return events; +} + +function closeTopLevelTextForStep(state: CursorSdkMapperState): RuntimeEvent[] { + const events: RuntimeEvent[] = []; + closeTextItem(state, "assistant", events); + closeTextItem(state, "thinking", events); + return events; +} + +function closeTextItem( + state: CursorSdkMapperState, + kind: "assistant" | "thinking" | "summary" | "task", + events: RuntimeEvent[], +): void { + const item = + kind === "assistant" + ? state.assistantItem + : kind === "thinking" + ? state.thinkingItem + : kind === "summary" + ? state.summaryItem + : state.taskItem; + if (!item) return; + events.push({ type: "item.completed", threadId: state.threadId, itemId: item.itemId }); + if (kind === "assistant") delete state.assistantItem; + else if (kind === "thinking") delete state.thinkingItem; + else if (kind === "summary") delete state.summaryItem; + else delete state.taskItem; +} + +function mapUserEcho(text: string, state: CursorSdkMapperState): RuntimeEvent[] { + if (state.userEchoSeen) return []; + state.userEchoSeen = true; + if (state.optimisticUserItemId) return []; + const itemId = newItemId("user"); + return [ + { + type: "item.started", + threadId: state.threadId, + itemId, + itemType: "user_message", + payload: { content: [{ kind: "text", text }] }, + }, + { type: "item.completed", threadId: state.threadId, itemId }, + ]; +} + +function ensureSummaryItem(state: CursorSdkMapperState): RuntimeEvent[] { + if (state.summaryItem) return []; + const itemId = newItemId("summary"); + state.summaryItem = { itemId, text: "" }; + return [ + { + type: "item.started", + threadId: state.threadId, + itemId, + itemType: "reasoning", + payload: { summary: "" }, + }, + ]; +} + +function updateSummaryItem(state: CursorSdkMapperState, summary: string): RuntimeEvent[] { + const events = ensureSummaryItem(state); + const item = state.summaryItem!; + if (item.text === summary) return events; + item.text = summary; + events.push({ + type: "item.updated", + threadId: state.threadId, + itemId: item.itemId, + payload: { summary }, + }); + return events; +} + +function completeSummaryItem(state: CursorSdkMapperState): RuntimeEvent[] { + const events: RuntimeEvent[] = []; + closeTextItem(state, "summary", events); + return events; +} + +function mapTaskMessage( + state: CursorSdkMapperState, + status: string | undefined, + text: string | undefined, +): RuntimeEvent[] { + if (!text && !status) return []; + const events: RuntimeEvent[] = []; + if (!state.taskItem) { + const itemId = newItemId("task-status"); + state.taskItem = { itemId, text: "" }; + events.push({ + type: "item.started", + threadId: state.threadId, + itemId, + itemType: "reasoning", + }); + } + const item = state.taskItem; + const summary = text ?? status!; + if (item.text !== summary) { + item.text = summary; + events.push({ + type: "item.updated", + threadId: state.threadId, + itemId: item.itemId, + payload: { summary }, + }); + } + if (status && /^(?:complete|completed|finished|success|error|failed|cancelled)$/i.test(status)) { + closeTextItem(state, "task", events); + } + return events; +} + +function mapUsage( + state: CursorSdkMapperState, + usage: CursorSdkTokenUsage | Omit, + source: "raw" | "normalized", +): RuntimeEvent[] { + const normalized = normalizeCursorSdkUsage(usage); + const fingerprint = usageFingerprint(normalized); + // Each source waits for its counterpart's identical delivery: a match cancels + // the queued peer entry, otherwise this delivery queues for its own echo. + const [peerQueue, ownQueue] = + source === "normalized" + ? [state.pendingRawUsageFingerprints, state.pendingNormalizedUsageFingerprints] + : [state.pendingNormalizedUsageFingerprints, state.pendingRawUsageFingerprints]; + const matchingPeer = peerQueue.indexOf(fingerprint); + if (matchingPeer >= 0) { + peerQueue.splice(matchingPeer, 1); + return []; + } + ownQueue.push(fingerprint); + + state.usageSequence += 1; + const runId = state.currentRunId ?? state.currentTurnId ?? state.threadId; + const sampleId = `${runId}:turn-${state.usageSequence}`; + // Cursor documents this as per-turn spend, which can aggregate several + // model/tool-loop calls. It is not current context-window occupancy, so the + // exact mapping is `usage.spent` only. + return [ + { + type: "usage.spent", + threadId: state.threadId, + usage: { + counterKind: "per-call", + counter: normalized.totalTokens, + scopeId: state.agentId ?? runId, + epoch: 0, + sampleId, + ...(state.currentTurnId ? { turnId: state.currentTurnId } : {}), + occurredAt: Date.now(), + ...(state.model ? { model: state.model } : {}), + }, + }, + ]; +} + +function finishCursorSdkRun( + state: CursorSdkMapperState, + runId: string, + turnState: "completed" | "failed" | "cancelled", + errorMessage?: string, +): RuntimeEvent[] { + if (state.terminalRunIds.has(runId)) return []; + state.terminalRunIds.add(runId); + const events = closeCursorSdkOpenItems(state); + if (errorMessage && !state.emittedErrors.has(errorMessage)) { + state.emittedErrors.add(errorMessage); + events.push({ type: "error", threadId: state.threadId, message: errorMessage }); + } + events.push({ + type: "turn.completed", + threadId: state.threadId, + turnId: state.currentTurnId ?? runId, + state: turnState, + }); + return events; +} + +function usageFingerprint(usage: ReturnType): string { + return [ + usage.inputTokens, + usage.outputTokens, + usage.cacheReadTokens, + usage.cacheWriteTokens, + usage.reasoningTokens ?? "", + usage.totalTokens, + ].join(":"); +} + +/** + * Consume a normalized stream chunk from the FIFO raw-delta queue. + * + * Current SDK versions emit one normalized message per raw update, but this + * also tolerates batching/splitting so a future transport optimization does + * not re-paint text that already arrived through `onDelta`. + */ +function consumeQueuedText(queue: string[], text: string): boolean { + if (text.length === 0 || queue.length === 0) return false; + const next = [...queue]; + let remaining = text; + while (remaining.length > 0) { + const head = next[0]; + if (head === undefined) return false; + if (remaining.startsWith(head)) { + next.shift(); + remaining = remaining.slice(head.length); + continue; + } + if (head.startsWith(remaining)) { + next[0] = head.slice(remaining.length); + remaining = ""; + break; + } + return false; + } + queue.splice(0, queue.length, ...next); + return true; +} diff --git a/src/supervisor/agents/cursor/sdkCanonicalMappingState.ts b/src/supervisor/agents/cursor/sdkCanonicalMappingState.ts new file mode 100644 index 000000000..4545019bf --- /dev/null +++ b/src/supervisor/agents/cursor/sdkCanonicalMappingState.ts @@ -0,0 +1,73 @@ +import type { CanonicalItemType, ToolCallProgress } from "@/shared/contracts"; + +export interface CursorSdkTextItem { + itemId: string; + text: string; +} + +export interface CursorSdkToolItem { + key: string; + callId: string; + itemId: string; + itemType: CanonicalItemType; + name: string; + classificationName: string; + args: unknown; + status: "running" | "success" | "error"; + result?: unknown; + progress?: ToolCallProgress; + lastPayloadFingerprint?: string; + outputText: string; +} + +export interface CursorSdkMapperState { + threadId: string; + currentTurnId?: string; + currentRunId?: string; + agentId?: string; + model?: string; + sessionStarted: boolean; + optimisticUserItemId?: string; + userEchoSeen: boolean; + assistantOutputSeen: boolean; + assistantItem?: CursorSdkTextItem; + thinkingItem?: CursorSdkTextItem; + summaryItem?: CursorSdkTextItem; + taskItem?: CursorSdkTextItem; + nestedAssistantItems: Map; + nestedThinkingItems: Map; + toolItems: Map; + completedToolKeys: Set; + /** Raw onDelta chunks awaiting their identical normalized stream echo. */ + pendingRawAssistantDeltas: string[]; + pendingRawThinkingDeltas: string[]; + pendingRawThinkingCompletions: number[]; + pendingRawTaskTexts: string[]; + terminalRunIds: Set; + emittedErrors: Set; + usageSequence: number; + pendingRawUsageFingerprints: string[]; + pendingNormalizedUsageFingerprints: string[]; +} + +export function createCursorSdkMapperState(threadId: string): CursorSdkMapperState { + return { + threadId, + sessionStarted: false, + userEchoSeen: false, + assistantOutputSeen: false, + nestedAssistantItems: new Map(), + nestedThinkingItems: new Map(), + toolItems: new Map(), + completedToolKeys: new Set(), + pendingRawAssistantDeltas: [], + pendingRawThinkingDeltas: [], + pendingRawThinkingCompletions: [], + pendingRawTaskTexts: [], + terminalRunIds: new Set(), + emittedErrors: new Set(), + usageSequence: 0, + pendingRawUsageFingerprints: [], + pendingNormalizedUsageFingerprints: [], + }; +} diff --git a/src/supervisor/agents/cursor/sdkCanonicalToolMapping.ts b/src/supervisor/agents/cursor/sdkCanonicalToolMapping.ts new file mode 100644 index 000000000..418abf8de --- /dev/null +++ b/src/supervisor/agents/cursor/sdkCanonicalToolMapping.ts @@ -0,0 +1,509 @@ +/** + * Cursor SDK tool and nested-task lifecycle mapping. + * + * Kept separate from text/turn mapping because tool calls have their own + * reconciliation state: raw callbacks and normalized stream events share a + * call id, partial arguments update in place, and subagent child items are + * grouped under the task tool. + */ + +import type { RuntimeEvent, ToolCallProgress } from "@/shared/contracts"; +import { readStringField } from "../fileChangeSummary"; +import { newItemId } from "../contextUsage"; +import type { CursorSdkMapperState, CursorSdkToolItem } from "./sdkCanonicalMappingState"; +import { + classifyCursorSdkTool, + cursorSdkRawToolResultIsError, + cursorSdkToolKey, + cursorSdkToolPayload, + cursorSdkToolProgress, + descriptorFromNormalizedTool, + descriptorFromRawTool, + readCursorSdkShellOutput, + safeCursorSdkFingerprint, + unwrapCursorSdkRawToolResult, + type CursorSdkToolDescriptor, +} from "./sdkCanonicalToolPayload"; +import type { + CursorSdkNestedTaskUpdate, + CursorSdkRawToolCall, + CursorSdkToolCallMessage, +} from "./sdkProtocol"; + +export { classifyCursorSdkTool } from "./sdkCanonicalToolPayload"; + +/** Overlap search window for command-output snapshot reconciliation (bytes). */ +const OVERLAP_WINDOW = 64 * 1024; + +export function mapCursorSdkAssistantToolUse( + state: CursorSdkMapperState, + callId: string, + name: string, + input: unknown, +): RuntimeEvent[] { + const events: RuntimeEvent[] = []; + ensureToolItem(state, callId, descriptorFromNormalizedTool(name, input), events); + return events; +} + +export function mapCursorSdkNormalizedToolCall( + message: CursorSdkToolCallMessage, + state: CursorSdkMapperState, +): RuntimeEvent[] { + const key = cursorSdkToolKey(message.call_id); + if (state.completedToolKeys.has(key)) return []; + const events: RuntimeEvent[] = []; + const descriptor = descriptorFromNormalizedTool(message.name, message.args); + const tool = ensureToolItem(state, message.call_id, descriptor, events); + if (!tool) return events; + if (message.args !== undefined) tool.args = message.args; + if (message.status === "running") { + updateToolItem(state, tool, "running", undefined, events); + } else { + const isError = message.status === "error" || cursorSdkRawToolResultIsError(message.result); + completeToolItem( + state, + tool, + isError ? "error" : "success", + unwrapCursorSdkRawToolResult(message.result), + events, + ); + } + return events; +} + +export function mapCursorSdkRawToolUpdate( + state: CursorSdkMapperState, + callId: string, + rawToolCall: CursorSdkRawToolCall, + complete: boolean, + parentCallId?: string, +): RuntimeEvent[] { + const events: RuntimeEvent[] = []; + const key = cursorSdkToolKey(callId, parentCallId); + if (state.completedToolKeys.has(key)) return events; + const descriptor = descriptorFromRawTool(rawToolCall); + const parentItemId = parentCallId + ? state.toolItems.get(cursorSdkToolKey(parentCallId))?.itemId + : undefined; + const tool = ensureToolItem(state, callId, descriptor, events, parentCallId, parentItemId); + if (!tool) return events; + if (rawToolCall.args !== undefined) tool.args = rawToolCall.args; + if (!complete) { + updateToolItem(state, tool, "running", undefined, events); + return events; + } + const result = unwrapCursorSdkRawToolResult(rawToolCall.result); + const status = cursorSdkRawToolResultIsError(rawToolCall.result) ? "error" : "success"; + completeToolItem(state, tool, status, result, events); + return events; +} + +export function mapCursorSdkNestedTaskUpdate( + state: CursorSdkMapperState, + parentCallId: string, + update: CursorSdkNestedTaskUpdate, +): RuntimeEvent[] { + const events: RuntimeEvent[] = []; + const parent = ensureParentTaskItem(state, parentCallId, events); + if (!parent) return events; + const parentItemId = parent.itemId; + + switch (update.type) { + case "text-delta": + appendNestedTextDelta(state, parentCallId, parentItemId, "assistant", update.text, events); + updateParentProgress( + state, + parent, + { + description: state.nestedAssistantItems.get(parentCallId)?.text ?? update.text, + }, + events, + ); + break; + case "thinking-delta": + appendNestedTextDelta(state, parentCallId, parentItemId, "thinking", update.text, events); + break; + case "thinking-completed": { + const thinking = state.nestedThinkingItems.get(parentCallId); + if (thinking) { + events.push({ + type: "item.updated", + threadId: state.threadId, + itemId: thinking.itemId, + payload: { summary: thinking.text, durationMs: update.thinkingDurationMs }, + }); + } + closeNestedTextItem(state, parentCallId, "thinking", events); + break; + } + case "tool-call-started": + case "partial-tool-call": { + const childWasKnown = state.toolItems.has(cursorSdkToolKey(update.callId, parentCallId)); + events.push( + ...mapCursorSdkRawToolUpdate(state, update.callId, update.toolCall, false, parentCallId), + ); + updateParentProgress( + state, + parent, + { + lastToolName: update.toolCall.type, + ...(!childWasKnown ? { stepCount: nextStepCount(parent) } : {}), + }, + events, + ); + break; + } + case "tool-call-completed": + events.push( + ...mapCursorSdkRawToolUpdate(state, update.callId, update.toolCall, true, parentCallId), + ); + updateParentProgress(state, parent, { lastToolName: update.toolCall.type }, events); + break; + case "step-started": + break; + case "step-completed": + closeNestedTextItem(state, parentCallId, "assistant", events); + closeNestedTextItem(state, parentCallId, "thinking", events); + updateParentProgress(state, parent, { durationMs: update.stepDurationMs }, events); + break; + } + return events; +} + +export function mapCursorSdkShellOutputDelta( + state: CursorSdkMapperState, + rawEvent: Record, +): RuntimeEvent[] { + const events: RuntimeEvent[] = []; + const callId = readStringField(rawEvent, "callId", "call_id", "toolCallId", "tool_call_id"); + // Official 1.0.24 shell-output deltas do not carry a call id. Route those + // only when one command is open; parallel commands are reconciled from + // their call-specific completion snapshots instead of guessing and + // attaching output to the wrong command. + const key = callId ? cursorSdkToolKey(callId) : soleOpenCommandKey(state); + const tool = key ? state.toolItems.get(key) : undefined; + if (!tool || tool.itemType !== "command_execution") return events; + const output = readCursorSdkShellOutput(rawEvent); + if (!output) return events; + appendToolOutputDelta(state, tool, output, events); + return events; +} + +function soleOpenCommandKey(state: CursorSdkMapperState): string | undefined { + let key: string | undefined; + for (const tool of state.toolItems.values()) { + if (tool.itemType !== "command_execution") continue; + if (key !== undefined) return undefined; + key = tool.key; + } + return key; +} + +export function closeCursorSdkToolItems(state: CursorSdkMapperState): RuntimeEvent[] { + const events: RuntimeEvent[] = []; + for (const parentCallId of [...state.nestedAssistantItems.keys()]) { + closeNestedTextItem(state, parentCallId, "assistant", events); + } + for (const parentCallId of [...state.nestedThinkingItems.keys()]) { + closeNestedTextItem(state, parentCallId, "thinking", events); + } + const openTools = [...state.toolItems.values()].sort( + (left, right) => toolKeyDepth(right.key) - toolKeyDepth(left.key), + ); + for (const tool of openTools) { + events.push({ type: "item.completed", threadId: state.threadId, itemId: tool.itemId }); + state.completedToolKeys.add(tool.key); + } + state.toolItems.clear(); + return events; +} + +function ensureToolItem( + state: CursorSdkMapperState, + callId: string, + descriptor: CursorSdkToolDescriptor, + events: RuntimeEvent[], + parentCallId?: string, + parentItemId?: string, +): CursorSdkToolItem | undefined { + const key = cursorSdkToolKey(callId, parentCallId); + if (state.completedToolKeys.has(key)) return undefined; + const existing = state.toolItems.get(key); + if (existing) { + if (descriptor.args !== undefined) existing.args = descriptor.args; + return existing; + } + + const itemType = classifyCursorSdkTool(descriptor.classificationName); + const tool: CursorSdkToolItem = { + key, + callId, + itemId: newItemId("cursor-tool"), + itemType, + name: descriptor.name, + classificationName: descriptor.classificationName, + args: descriptor.args, + status: "running", + outputText: "", + }; + const progress = cursorSdkToolProgress(tool); + if (progress) tool.progress = progress; + state.toolItems.set(key, tool); + events.push({ + type: "item.started", + threadId: state.threadId, + itemId: tool.itemId, + itemType, + payload: cursorSdkToolPayload(tool), + ...(parentItemId ? { parentItemId } : {}), + }); + return tool; +} + +function updateToolItem( + state: CursorSdkMapperState, + tool: CursorSdkToolItem, + status: "running" | "success" | "error", + result: unknown, + events: RuntimeEvent[], +): Record { + tool.status = status; + if (result !== undefined) tool.result = result; + const nextProgress = cursorSdkToolProgress(tool); + if (nextProgress) tool.progress = { ...tool.progress, ...nextProgress }; + const payload = cursorSdkToolPayload(tool); + const fingerprint = safeCursorSdkFingerprint(payload); + if (fingerprint === tool.lastPayloadFingerprint) return payload; + tool.lastPayloadFingerprint = fingerprint; + events.push({ + type: "item.updated", + threadId: state.threadId, + itemId: tool.itemId, + payload, + }); + return payload; +} + +function completeToolItem( + state: CursorSdkMapperState, + tool: CursorSdkToolItem, + status: "success" | "error", + result: unknown, + events: RuntimeEvent[], +): void { + closeNestedItemsForParent(state, tool.callId, events); + appendCompletionOutput(state, tool, result, events); + // `updateToolItem` already built the final payload; the completion event + // carries the same snapshot, so reuse it instead of re-serializing. + const payload = updateToolItem(state, tool, status, result, events); + events.push({ + type: "item.completed", + threadId: state.threadId, + itemId: tool.itemId, + payload, + }); + state.toolItems.delete(tool.key); + state.completedToolKeys.add(tool.key); +} + +function ensureParentTaskItem( + state: CursorSdkMapperState, + parentCallId: string, + events: RuntimeEvent[], +): CursorSdkToolItem | undefined { + const existing = state.toolItems.get(cursorSdkToolKey(parentCallId)); + if (existing) return existing; + return ensureToolItem( + state, + parentCallId, + { name: "Task", classificationName: "task", args: {} }, + events, + ); +} + +function appendNestedTextDelta( + state: CursorSdkMapperState, + parentCallId: string, + parentItemId: string, + kind: "assistant" | "thinking", + delta: string, + events: RuntimeEvent[], +): void { + if (delta.length === 0) return; + const items = kind === "assistant" ? state.nestedAssistantItems : state.nestedThinkingItems; + let item = items.get(parentCallId); + if (!item) { + item = { + itemId: newItemId(kind === "assistant" ? "sub-asst" : "sub-reason"), + text: "", + }; + items.set(parentCallId, item); + events.push({ + type: "item.started", + threadId: state.threadId, + itemId: item.itemId, + itemType: kind === "assistant" ? "assistant_message" : "reasoning", + parentItemId, + }); + } + item.text += delta; + events.push({ + type: "content.delta", + threadId: state.threadId, + itemId: item.itemId, + stream: kind === "assistant" ? "assistant_text" : "reasoning_text", + delta, + }); +} + +function closeNestedTextItem( + state: CursorSdkMapperState, + parentCallId: string, + kind: "assistant" | "thinking", + events: RuntimeEvent[], +): void { + const items = kind === "assistant" ? state.nestedAssistantItems : state.nestedThinkingItems; + const item = items.get(parentCallId); + if (!item) return; + events.push({ type: "item.completed", threadId: state.threadId, itemId: item.itemId }); + items.delete(parentCallId); +} + +function closeNestedItemsForParent( + state: CursorSdkMapperState, + parentCallId: string, + events: RuntimeEvent[], +): void { + closeNestedTextItem(state, parentCallId, "assistant", events); + closeNestedTextItem(state, parentCallId, "thinking", events); + const prefix = `${parentCallId}/`; + for (const tool of [...state.toolItems.values()]) { + if (!tool.key.startsWith(prefix)) continue; + updateToolItem(state, tool, "error", undefined, events); + events.push({ type: "item.completed", threadId: state.threadId, itemId: tool.itemId }); + state.toolItems.delete(tool.key); + state.completedToolKeys.add(tool.key); + } +} + +function updateParentProgress( + state: CursorSdkMapperState, + parent: CursorSdkToolItem, + progress: ToolCallProgress, + events: RuntimeEvent[], +): void { + parent.progress = { ...parent.progress, ...progress }; + updateToolItem(state, parent, "running", undefined, events); +} + +function nextStepCount(tool: CursorSdkToolItem): number { + return (tool.progress?.stepCount ?? 0) + 1; +} + +function appendCompletionOutput( + state: CursorSdkMapperState, + tool: CursorSdkToolItem, + result: unknown, + events: RuntimeEvent[], +): void { + if (tool.itemType === "command_execution") { + const output = readCursorSdkShellOutput(result); + if (output) appendToolOutputSnapshot(state, tool, output, events); + return; + } + if (tool.itemType === "file_change") { + const diff = readStringField(result, "diffString", "diff", "patch"); + if (diff) { + events.push({ + type: "content.delta", + threadId: state.threadId, + itemId: tool.itemId, + stream: "file_change_output", + delta: diff, + }); + } + } +} + +function appendToolOutputSnapshot( + state: CursorSdkMapperState, + tool: CursorSdkToolItem, + output: string, + events: RuntimeEvent[], +): void { + if (output === tool.outputText) return; + const tail = output.startsWith(tool.outputText) + ? output.slice(tool.outputText.length) + : output.slice(suffixPrefixOverlap(tool.outputText, output)); + if (!tail) return; + tool.outputText += tail; + events.push({ + type: "content.delta", + threadId: state.threadId, + itemId: tool.itemId, + stream: "command_output", + delta: tail, + }); +} + +function appendToolOutputDelta( + state: CursorSdkMapperState, + tool: CursorSdkToolItem, + delta: string, + events: RuntimeEvent[], +): void { + if (!delta) return; + tool.outputText += delta; + events.push({ + type: "content.delta", + threadId: state.threadId, + itemId: tool.itemId, + stream: "command_output", + delta, + }); +} + +/** + * Longest suffix of `emitted` that is also a prefix of `full`, i.e. how much of + * a rewritten command snapshot was already streamed. + * + * Command output reaches hundreds of kilobytes, so this runs a linear KMP scan + * (no per-candidate substring allocation) over a bounded tail/head window. The + * window caps worst-case work on huge snapshots; real overlaps are the streamed + * tail of the same buffer, far below the limit. + */ +function suffixPrefixOverlap(emitted: string, full: string): number { + const pattern = full.length > OVERLAP_WINDOW ? full.slice(0, OVERLAP_WINDOW) : full; + const text = emitted.length > OVERLAP_WINDOW ? emitted.slice(-OVERLAP_WINDOW) : emitted; + if (pattern.length === 0 || text.length === 0) return 0; + const fallback = prefixFunction(pattern); + let matched = 0; + for (let index = 0; index < text.length; index += 1) { + const char = text[index]; + while (matched > 0 && char !== pattern[matched]) matched = fallback[matched - 1]!; + if (char === pattern[matched]) matched += 1; + if (matched === pattern.length) { + if (index === text.length - 1) return matched; + matched = fallback[matched - 1]!; + } + } + return matched; +} + +/** KMP failure table: longest proper prefix that is also a suffix, per index. */ +function prefixFunction(pattern: string): Uint32Array { + const fallback = new Uint32Array(pattern.length); + let length = 0; + for (let index = 1; index < pattern.length; index += 1) { + const char = pattern[index]; + while (length > 0 && char !== pattern[length]) length = fallback[length - 1]!; + if (char === pattern[length]) length += 1; + fallback[index] = length; + } + return fallback; +} + +function toolKeyDepth(key: string): number { + return key.split("/").length; +} diff --git a/src/supervisor/agents/cursor/sdkCanonicalToolPayload.ts b/src/supervisor/agents/cursor/sdkCanonicalToolPayload.ts new file mode 100644 index 000000000..c2fd070ca --- /dev/null +++ b/src/supervisor/agents/cursor/sdkCanonicalToolPayload.ts @@ -0,0 +1,394 @@ +/** + * Defensive normalization of Cursor's intentionally unstable tool payloads. + * + * Cursor guarantees the call envelope but not individual tool names or + * argument/result schemas. These readers recognize the 1.0.24 built-ins while + * preserving unknown future tools as generic canonical tool calls. + */ + +import type { CanonicalItemType, ToolCallProgress } from "@/shared/contracts"; +import { + classifyFileChangeKind, + normalizeDiffSummaryForKind, + type FileChangeKind, +} from "../fileChangeKind"; +import { readDiffSummary, readFileChangePath, readStringField } from "../fileChangeSummary"; +import type { CursorSdkToolItem } from "./sdkCanonicalMappingState"; +import type { CursorSdkRawToolCall, CursorSdkTokenUsage } from "./sdkProtocol"; + +export interface CursorSdkToolDescriptor { + name: string; + classificationName: string; + args: unknown; +} + +export function descriptorFromRawTool(toolCall: CursorSdkRawToolCall): CursorSdkToolDescriptor { + const rawArgs = toolCall.args; + if (normalizeToolName(toolCall.type) === "mcp") { + return { + name: readStringField(rawArgs, "toolName", "tool_name") ?? "MCP", + classificationName: "mcp", + args: rawArgs, + }; + } + return { + name: displayToolName(toolCall.type), + classificationName: toolCall.type, + args: rawArgs, + }; +} + +export function descriptorFromNormalizedTool(name: string, args: unknown): CursorSdkToolDescriptor { + return { name, classificationName: name, args }; +} + +export function classifyCursorSdkTool(toolName: string): CanonicalItemType { + const name = normalizeToolName(toolName); + if (name === "createplan" || name === "updatetodos" || name.includes("todo")) return "plan"; + if ( + name === "shell" || + name.includes("bash") || + name.includes("command") || + name.includes("terminal") + ) { + return "command_execution"; + } + if ( + name === "write" || + name === "edit" || + name === "delete" || + name.includes("patch") || + name.includes("replace") + ) { + return "file_change"; + } + if (name === "mcp" || name.startsWith("mcp")) return "mcp_tool_call"; + if (name.includes("websearch") || name.includes("webfetch")) return "web_search"; + if (name === "generateimage" || name.includes("image")) return "image_view"; + if (name === "task" || name.includes("subagent")) return "tool_call"; + return "dynamic_tool_call"; +} + +export function cursorSdkToolPayload(tool: CursorSdkToolItem): Record { + const args = objectValue(tool.args); + const result = tool.result; + const errorMessage = tool.status === "error" ? readErrorMessage(result) : undefined; + + if (tool.itemType === "command_execution") { + const resultValue = objectValue(result); + const exitCode = readFiniteInteger(resultValue?.exitCode); + const durationMs = readFiniteNonNegative(resultValue?.executionTime); + const cwd = readStringField(args, "workingDirectory", "cwd"); + return { + command: readStringField(args, "command", "cmd") ?? tool.name, + ...(cwd ? { cwd } : {}), + status: tool.status, + ...(exitCode !== undefined ? { exitCode } : {}), + ...(durationMs !== undefined ? { durationMs } : {}), + ...(errorMessage ? { errorMessage } : {}), + }; + } + + if (tool.itemType === "file_change") { + const path = readFileChangePath(args, result) ?? ""; + const diffText = readStringField(result, "diffString", "diff", "patch"); + const diffSummary = readDiffSummary(args, result, diffText); + const changeKind = classifyCursorFileChangeKind(tool, args, result, diffText); + return { + name: tool.name, + path, + changeKind, + status: tool.status, + args: tool.args, + ...(result !== undefined ? { result } : {}), + ...(diffSummary ? { diffSummary: normalizeDiffSummaryForKind(changeKind, diffSummary) } : {}), + ...(errorMessage ? { errorMessage } : {}), + }; + } + + if (tool.itemType === "plan") { + return { steps: planSteps(tool.args) }; + } + + if (tool.itemType === "web_search") { + const resultCount = readFiniteInteger(objectValue(result)?.resultCount); + return { + query: readStringField(args, "query", "url") ?? tool.name, + ...(resultCount !== undefined ? { resultCount } : {}), + }; + } + + const kind = inferToolKind(tool.classificationName); + const path = readFileChangePath(args); + const serverId = readStringField(args, "providerIdentifier", "serverId", "server_id"); + const mcpArgs = + tool.itemType === "mcp_tool_call" && args && "args" in args ? args.args : tool.args; + const images = readToolImages(tool.classificationName, result); + return { + name: tool.name, + ...(kind ? { kind } : {}), + ...(path ? { locations: [{ path }] } : {}), + ...(serverId ? { serverId } : {}), + args: mcpArgs, + ...(result !== undefined ? { result } : {}), + status: tool.status, + ...(tool.progress ? { progress: tool.progress } : {}), + ...(isSubAgentTool(tool.classificationName) ? { isSubAgent: true } : {}), + ...(images.length > 0 ? { images } : {}), + ...(errorMessage ? { errorMessage } : {}), + }; +} + +export function cursorSdkToolProgress(tool: CursorSdkToolItem): ToolCallProgress | undefined { + if (!isSubAgentTool(tool.classificationName)) return tool.progress; + const args = objectValue(tool.args); + const result = objectValue(tool.result); + const description = readStringField(args, "description", "prompt"); + const model = readStringField(args, "model"); + const summary = readStringField(result, "resultSuffix", "summary"); + const durationMs = readFiniteNonNegative(result?.durationMs); + return { + ...(description ? { description } : {}), + ...(model ? { model } : {}), + ...tool.progress, + ...(summary ? { summary } : {}), + ...(durationMs !== undefined ? { durationMs } : {}), + }; +} + +export function unwrapCursorSdkRawToolResult(result: unknown): unknown { + const record = objectValue(result); + if (!record) return result; + if ("value" in record) return record.value; + if ("error" in record) return record.error; + return result; +} + +export function cursorSdkRawToolResultIsError(result: unknown): boolean { + const record = objectValue(result); + return ( + record?.status === "error" || + record?.isError === true || + objectValue(record?.value)?.isError === true + ); +} + +export function readCursorSdkShellOutput(value: unknown): string | undefined { + const record = objectValue(value); + if (!record) return typeof value === "string" && value.length > 0 ? value : undefined; + const nested = + objectValue(record.value) ?? objectValue(record.output) ?? objectValue(record.event); + const source = nested ?? record; + const stdout = readUntrimmedString(source, "stdout", "output", "text", "data", "delta", "chunk"); + const stderr = readUntrimmedString(source, "stderr"); + const combined = `${stdout ?? ""}${stderr ?? ""}`; + return combined.length > 0 ? combined : undefined; +} + +export function cursorSdkToolKey(callId: string, parentCallId?: string): string { + return parentCallId ? `${parentCallId}/${callId}` : callId; +} + +export function safeCursorSdkFingerprint(value: unknown): string { + try { + return JSON.stringify(value); + } catch { + return String(value); + } +} + +export function normalizeCursorSdkUsage( + usage: CursorSdkTokenUsage | Omit, +): Required> & { reasoningTokens?: number } { + const inputTokens = nonNegativeInteger(usage.inputTokens); + const outputTokens = nonNegativeInteger(usage.outputTokens); + const cacheReadTokens = nonNegativeInteger(usage.cacheReadTokens); + const cacheWriteTokens = nonNegativeInteger(usage.cacheWriteTokens); + const reasoningTokens = + usage.reasoningTokens === undefined ? undefined : nonNegativeInteger(usage.reasoningTokens); + const totalTokens = + "totalTokens" in usage && typeof usage.totalTokens === "number" + ? nonNegativeInteger(usage.totalTokens) + : inputTokens + outputTokens + cacheReadTokens + cacheWriteTokens; + return { + inputTokens, + outputTokens, + cacheReadTokens, + cacheWriteTokens, + totalTokens, + ...(reasoningTokens !== undefined ? { reasoningTokens } : {}), + }; +} + +function planSteps( + value: unknown, +): Array<{ step: string; status: "pending" | "in_progress" | "completed" }> { + const args = objectValue(value); + if (Array.isArray(args?.todos)) { + return args.todos.flatMap((todo) => { + const item = objectValue(todo); + const step = readStringField(item, "content", "text", "title"); + if (!step) return []; + const rawStatus = readStringField(item, "status")?.toLowerCase(); + const status: "pending" | "in_progress" | "completed" = + rawStatus === "completed" + ? "completed" + : rawStatus === "inprogress" || rawStatus === "in_progress" + ? "in_progress" + : "pending"; + return [{ step, status }]; + }); + } + const plan = readStringField(args, "plan"); + if (!plan) return []; + return plan + .split(/\r?\n/) + .map((line) => line.trim()) + .filter((line) => line.length > 0 && !/^#+\s*$/.test(line)) + .map((line) => { + const completed = /^[-*]\s+\[[xX]\]\s+/.test(line); + const inProgress = /^[-*]\s+\[[-~]\]\s+/.test(line); + const step = line + .replace(/^#+\s*/, "") + .replace(/^[-*]\s+(?:\[[ xX~-]\]\s+)?/, "") + .trim(); + const status: "pending" | "in_progress" | "completed" = completed + ? "completed" + : inProgress + ? "in_progress" + : "pending"; + return { step, status }; + }) + .filter((entry) => entry.step.length > 0); +} + +function inferToolKind( + toolName: string, +): "read" | "search" | "execute" | "fetch" | "other" | undefined { + const name = normalizeToolName(toolName); + if (name === "read" || name === "readlints" || name === "ls") return "read"; + if (name === "glob" || name === "grep" || name === "semsearch") return "search"; + if (name === "shell") return "execute"; + if (name.includes("fetch")) return "fetch"; + if (name === "recordscreen") return "other"; + return undefined; +} + +/** + * Classify a Cursor file change through the shared cross-provider rules so + * structured `changes`, diff/patch evidence and explicit kind fields behave + * exactly as they do for the other providers. + * + * Cursor's tool names carry real intent that the shared kind/title fallback + * cannot see (`write` creates or overwrites, `delete` removes), so a confident + * name reading is injected as a low-priority source: concrete payload evidence + * still wins, but the name beats the generic "default to edit" fallback. + * The name hint is deliberately consulted before the loosely typed `diffString` + * text, which would otherwise flip a `write`/`delete` badge to "edit" the + * moment the completion snapshot arrives. + */ +function classifyCursorFileChangeKind( + tool: CursorSdkToolItem, + args: Record | undefined, + result: unknown, + diffText: string | undefined, +): FileChangeKind { + const nameKind = fileChangeKindFromToolName(tool.classificationName); + return classifyFileChangeKind( + tool.classificationName, + tool.name, + args, + result, + ...(nameKind ? [{ changeKind: nameKind }] : []), + diffText, + ); +} + +function fileChangeKindFromToolName(toolName: string): FileChangeKind | undefined { + const name = normalizeToolName(toolName); + if (name === "write" || name.includes("create")) return "create"; + if (name === "delete" || name.includes("remove")) return "delete"; + return undefined; +} + +function isSubAgentTool(toolName: string): boolean { + const name = normalizeToolName(toolName); + return name === "task" || name.includes("subagent"); +} + +function displayToolName(name: string): string { + const labels: Record = { + createPlan: "Create Plan", + generateImage: "Generate Image", + readLints: "Read Lints", + recordScreen: "Record Screen", + semSearch: "Semantic Search", + updateTodos: "Update Todos", + }; + return labels[name] ?? name; +} + +function readToolImages(toolName: string, result: unknown): string[] { + const value = objectValue(result); + const images: string[] = []; + if (normalizeToolName(toolName) === "generateimage") { + const data = readUntrimmedString(value, "imageData"); + if (data) images.push(asDataUrl(data, "image/png")); + } + if (Array.isArray(value?.content)) { + for (const content of value.content) { + const image = objectValue(objectValue(content)?.image); + const data = readUntrimmedString(image, "data"); + if (!data) continue; + images.push(asDataUrl(data, readStringField(image, "mimeType") ?? "image/png")); + } + } + return images; +} + +function asDataUrl(data: string, mimeType: string): string { + return data.startsWith("data:") ? data : `data:${mimeType};base64,${data}`; +} + +function normalizeToolName(name: string): string { + return name.toLowerCase().replace(/[^a-z0-9]/g, ""); +} + +function objectValue(value: unknown): Record | undefined { + return value && typeof value === "object" && !Array.isArray(value) + ? (value as Record) + : undefined; +} + +function readUntrimmedString(value: unknown, ...keys: string[]): string | undefined { + const record = objectValue(value); + if (!record) return undefined; + for (const key of keys) { + const candidate = record[key]; + if (typeof candidate === "string" && candidate.length > 0) return candidate; + } + return undefined; +} + +function readErrorMessage(value: unknown): string | undefined { + if (typeof value === "string" && value.length > 0) return value; + const record = objectValue(value); + if (!record) return undefined; + return ( + readStringField(record, "message", "errorMessage", "error") ?? + readErrorMessage(record.error) ?? + readErrorMessage(record.value) + ); +} + +function readFiniteInteger(value: unknown): number | undefined { + return typeof value === "number" && Number.isFinite(value) ? Math.trunc(value) : undefined; +} + +function readFiniteNonNegative(value: unknown): number | undefined { + return typeof value === "number" && Number.isFinite(value) && value >= 0 ? value : undefined; +} + +function nonNegativeInteger(value: number): number { + return Number.isFinite(value) && value >= 0 ? Math.trunc(value) : 0; +} diff --git a/src/supervisor/agents/cursor/sdkDetection.test.ts b/src/supervisor/agents/cursor/sdkDetection.test.ts new file mode 100644 index 000000000..af0707f49 --- /dev/null +++ b/src/supervisor/agents/cursor/sdkDetection.test.ts @@ -0,0 +1,532 @@ +import { describe, expect, it, vi } from "vitest"; +import { CursorSdkWorkerRpcError } from "./sdkWorkerClient"; +import type { AgentStatus } from "@/shared/contracts"; +import { agentStatusForPresentation } from "@/shared/agentSelection"; +import { applyCursorSdkProbe, probeCursorSdkRuntime } from "./sdkDetection"; + +function worker(input: { + probe: () => Promise<{ + models: Array<{ id: string; displayName: string }>; + sdkVersion: string; + source: "configured" | "global-npm"; + }>; +}) { + return { + probe: input.probe, + dispose: vi.fn<() => Promise>().mockResolvedValue(undefined), + }; +} + +describe("probeCursorSdkRuntime", () => { + it("probes models inside the target runtime and always disposes the worker", async () => { + const handle = worker({ + probe: async () => ({ + models: [{ id: "composer-2.5", displayName: "Composer 2.5" }], + sdkVersion: "1.0.24", + source: "global-npm", + }), + }); + const spawnWorker = vi.fn<() => Promise>(async () => handle); + + await expect( + probeCursorSdkRuntime( + { + envKind: "wsl", + wslDistro: "Ubuntu", + agentSettings: { + sdkPackagePath: "\\\\wsl.localhost\\Ubuntu\\opt\\cursor-sdk", + }, + }, + { spawnWorker }, + ), + ).resolves.toEqual({ + installed: true, + authState: "authenticated", + models: [{ id: "composer-2.5", displayName: "Composer 2.5" }], + version: "1.0.24", + source: "global-npm", + }); + expect(spawnWorker).toHaveBeenCalledWith({ + projectLocation: expect.objectContaining({ + kind: "wsl", + distro: "Ubuntu", + linuxPath: "/tmp", + }), + configuredPath: "/opt/cursor-sdk", + }); + expect(handle.dispose).toHaveBeenCalledOnce(); + }); + + it("defers a missing environment-probe key but preserves rejected credentials", async () => { + const missingAuth = worker({ + probe: async () => { + throw new CursorSdkWorkerRpcError({ + name: "CursorSdkWorkerError", + message: "The Cursor SDK requires an API key.", + code: "auth_missing", + }); + }, + }); + await expect( + probeCursorSdkRuntime(undefined, { spawnWorker: async () => missingAuth }), + ).resolves.toMatchObject({ + installed: true, + authState: "unknown", + models: [], + projectDiscoveryDeferred: true, + diagnosticCode: "auth_missing", + }); + + const invalidAuth = worker({ + probe: async () => { + throw new CursorSdkWorkerRpcError({ + name: "CursorSdkWorkerError", + message: "Cursor rejected the configured SDK API key.", + code: "auth_invalid", + }); + }, + }); + await expect( + probeCursorSdkRuntime(undefined, { spawnWorker: async () => invalidAuth }), + ).resolves.toMatchObject({ + installed: true, + authState: "missing", + models: [], + diagnosticCode: "auth_invalid", + }); + + const missingPackage = worker({ + probe: async () => { + throw new CursorSdkWorkerRpcError({ + name: "CursorSdkWorkerError", + message: "No external Cursor SDK package was found.", + code: "package_missing", + }); + }, + }); + await expect( + probeCursorSdkRuntime(undefined, { spawnWorker: async () => missingPackage }), + ).resolves.toMatchObject({ + installed: false, + authState: "unknown", + models: [], + projectDiscoveryDeferred: true, + diagnosticCode: "package_missing", + }); + }); + + it("defers automatic project-local discovery from both native and WSL environment probes", async () => { + const missingPackage = worker({ + probe: async () => { + throw new CursorSdkWorkerRpcError({ + name: "CursorSdkWorkerError", + message: "No external Cursor SDK package was found.", + code: "package_missing", + }); + }, + }); + const spawnWorker = vi.fn<(_options: unknown) => Promise>( + async () => missingPackage, + ); + + await expect( + probeCursorSdkRuntime({ envKind: "posix" }, { spawnWorker }), + ).resolves.toMatchObject({ + installed: false, + authState: "unknown", + projectDiscoveryDeferred: true, + diagnosticCode: "package_missing", + }); + await expect( + probeCursorSdkRuntime({ envKind: "wsl", wslDistro: "Ubuntu" }, { spawnWorker }), + ).resolves.toMatchObject({ + installed: false, + authState: "unknown", + projectDiscoveryDeferred: true, + diagnosticCode: "package_missing", + }); + + expect(spawnWorker).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ + projectLocation: expect.objectContaining({ + kind: process.platform === "win32" ? "windows" : "posix", + }), + }), + ); + expect(spawnWorker).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ + projectLocation: expect.objectContaining({ + kind: "wsl", + distro: "Ubuntu", + linuxPath: "/tmp", + }), + }), + ); + }); + + it("keeps an invalid configured path exact instead of deferring to project discovery", async () => { + const invalidConfiguredPath = worker({ + probe: async () => { + throw new CursorSdkWorkerRpcError({ + name: "CursorSdkWorkerError", + message: "The configured Cursor SDK path is invalid.", + code: "configured_path_invalid", + }); + }, + }); + + await expect( + probeCursorSdkRuntime( + { + envKind: "wsl", + wslDistro: "Ubuntu", + agentSettings: { + sdkPackagePath: "\\\\wsl.localhost\\Ubuntu\\missing\\cursor-sdk", + }, + }, + { spawnWorker: async () => invalidConfiguredPath }, + ), + ).resolves.toEqual({ + installed: false, + authState: "unknown", + models: [], + diagnosticCode: "configured_path_invalid", + diagnosticMessage: "The configured Cursor SDK path is invalid.", + }); + }); + + it("classifies model-catalog 401s as missing API-key authentication", async () => { + const handle = worker({ + probe: async () => { + throw Object.assign(new Error("Unauthorized"), { code: 401 }); + }, + }); + await expect( + probeCursorSdkRuntime(undefined, { spawnWorker: async () => handle }), + ).resolves.toMatchObject({ + installed: true, + authState: "missing", + diagnosticCode: "401", + }); + }); + + it.each(["UNAUTHENTICATED", "BAD_API_KEY", "BAD_USER_API_KEY"])( + "classifies SDK auth code %s case-insensitively", + async (code) => { + const handle = worker({ + probe: async () => { + throw Object.assign(new Error("Unauthorized"), { code }); + }, + }); + await expect( + probeCursorSdkRuntime(undefined, { spawnWorker: async () => handle }), + ).resolves.toMatchObject({ + installed: true, + authState: "missing", + diagnosticCode: code, + }); + }, + ); + + it("keeps unclassified worker boot failures unavailable and privacy-safe", async () => { + await expect( + probeCursorSdkRuntime(undefined, { + spawnWorker: async () => { + throw new Error("worker helper missing"); + }, + }), + ).resolves.toEqual({ + installed: false, + authState: "unknown", + models: [], + diagnosticMessage: "worker helper missing", + }); + }); + + it("keeps an installed SDK available when only its catalog request is offline", async () => { + const handle = worker({ + probe: async () => { + throw new Error("catalog network unavailable"); + }, + }); + await expect( + probeCursorSdkRuntime(undefined, { spawnWorker: async () => handle }), + ).resolves.toEqual({ + installed: true, + authState: "unknown", + models: [], + diagnosticMessage: "catalog network unavailable", + }); + }); +}); + +const cliStatus: AgentStatus = { + kind: "cursor", + label: "Cursor", + installed: true, + authState: "authenticated", + loginCommand: "cursor-agent login", + executablePath: "/usr/bin/cursor-agent", + version: "2026.07.23", + capabilities: { + models: [{ id: "cli-model", label: "CLI Model" }], + efforts: [], + modelEfforts: { "cli-model": [] }, + modes: ["agent", "plan"], + approvalPolicies: [{ id: "never", label: "YOLO" }], + sandboxModes: [], + supportsResume: true, + supportsOneShot: true, + supportsDirectInput: true, + liveInputMode: "terminal", + presentationMode: "terminal", + presentationModes: ["terminal", "gui"], + settingDefs: [], + }, +}; + +describe("applyCursorSdkProbe", () => { + it("keeps terminal CLI capabilities while overriding GUI with SDK models and auth", () => { + const merged = applyCursorSdkProbe(cliStatus, { + installed: true, + authState: "missing", + version: "1.0.24", + source: "global-npm", + models: [{ id: "sdk-model", displayName: "SDK Model" }], + }); + + expect(merged.authState).toBe("authenticated"); + expect(merged.presentationAuthStates).toEqual({ + terminal: "authenticated", + gui: "missing", + }); + expect(merged.presentationAuthUsesProviderLogin).toEqual({ gui: false }); + expect(merged.capabilities.models).toEqual([{ id: "cli-model", label: "CLI Model" }]); + expect(merged.capabilities.presentationCapabilities?.gui?.models).toEqual([ + { id: "sdk-model", label: "SDK Model" }, + ]); + expect(merged.capabilities.mcpScope?.gui).toBe("always"); + expect(merged.runtimeVariants).toMatchObject({ + acp: { + presentationMode: "gui", + installed: true, + authState: "authenticated", + authUsesProviderLogin: true, + capabilities: { + models: [{ id: "cli-model", label: "CLI Model" }], + liveInputMode: "server", + presentationMode: "gui", + }, + }, + sdk: { + presentationMode: "gui", + installed: true, + authState: "missing", + authUsesProviderLogin: false, + capabilities: { + models: [{ id: "sdk-model", label: "SDK Model" }], + liveInputMode: "server", + presentationMode: "gui", + }, + }, + }); + expect(merged.sessionRuntimeRouting).toEqual({ + prefixes: { "sdk:": "sdk" }, + fallbackRuntime: "acp", + }); + }); + + it("keeps an existing SDK thread on SDK after the selected default switches to ACP", () => { + const selectedAcp = applyCursorSdkProbe( + cliStatus, + { + installed: true, + authState: "missing", + models: [{ id: "sdk-model", displayName: "SDK Model" }], + }, + "acp", + ); + + expect(selectedAcp.capabilities.models).toEqual([{ id: "cli-model", label: "CLI Model" }]); + const freshAcp = agentStatusForPresentation(selectedAcp, "gui"); + expect(freshAcp.capabilities.models).toEqual([{ id: "cli-model", label: "CLI Model" }]); + expect(freshAcp.capabilities.liveInputMode).toBe("server"); + expect(freshAcp.capabilities.presentationMode).toBe("gui"); + const existingSdk = agentStatusForPresentation(selectedAcp, "gui", { + providerSessionId: "sdk:agent-1", + discoveredAt: "2026-07-27T00:00:00.000Z", + }); + expect(existingSdk.installed).toBe(true); + expect(existingSdk.authState).toBe("missing"); + expect(existingSdk.loginCommand).toBeUndefined(); + expect(existingSdk.capabilities.models).toEqual([{ id: "sdk-model", label: "SDK Model" }]); + }); + + it("keeps an existing ACP thread on ACP after the selected default switches to SDK", () => { + const selectedSdk = applyCursorSdkProbe( + { + ...cliStatus, + authMethods: [ + { type: "terminal", id: "cursor-login", name: "Cursor login", args: ["login"] }, + ], + }, + { + installed: true, + authState: "authenticated", + models: [{ id: "sdk-model", displayName: "SDK Model" }], + }, + "sdk", + ); + + const freshGui = agentStatusForPresentation(selectedSdk, "gui"); + expect(freshGui.capabilities.models).toEqual([{ id: "sdk-model", label: "SDK Model" }]); + + const existingAcp = agentStatusForPresentation(selectedSdk, "gui", { + providerSessionId: "legacy-acp-session", + discoveredAt: "2026-07-27T00:00:00.000Z", + }); + expect(existingAcp.authState).toBe("authenticated"); + expect(existingAcp.loginCommand).toBe("cursor-agent login"); + expect(existingAcp.authMethods).toHaveLength(1); + expect(existingAcp.capabilities.models).toEqual([{ id: "cli-model", label: "CLI Model" }]); + }); + + it("advertises an SDK-only installation without CLI login/update affordances", () => { + const sdkOnly = applyCursorSdkProbe( + { + ...cliStatus, + installed: false, + authState: "missing", + update: { builtIn: { binary: "cursor-agent", args: ["update"] } }, + }, + { + installed: true, + authState: "authenticated", + version: "1.0.24", + models: [{ id: "sdk-model", displayName: "SDK Model" }], + }, + ); + + expect(sdkOnly).toMatchObject({ + installed: true, + authState: "authenticated", + version: "1.0.24", + presentationAuthStates: { gui: "authenticated" }, + capabilities: { + presentationMode: "gui", + presentationModes: ["gui"], + liveInputMode: "server", + supportsOneShot: false, + models: [{ id: "sdk-model", label: "SDK Model" }], + }, + }); + expect(sdkOnly.loginCommand).toBeUndefined(); + expect(sdkOnly.executablePath).toBeUndefined(); + expect(sdkOnly.update).toBeUndefined(); + expect(sdkOnly.runtimeVariants).toMatchObject({ + acp: { installed: false }, + sdk: { + installed: true, + capabilities: { + models: [{ id: "sdk-model", label: "SDK Model" }], + supportsOneShot: false, + }, + }, + }); + }); + + it("hides SDK GUI when the external package is unavailable", () => { + const merged = applyCursorSdkProbe(cliStatus, { + installed: false, + authState: "unknown", + models: [], + diagnosticCode: "package_missing", + }); + expect(merged.installed).toBe(true); + expect(merged.capabilities.presentationModes).toEqual(["terminal"]); + expect(merged.presentationAuthStates).toEqual({ terminal: "authenticated" }); + expect(merged.presentationAuthUsesProviderLogin).toBeUndefined(); + expect(merged.runtimeVariants?.sdk).toMatchObject({ + installed: false, + authState: "unknown", + capabilities: { models: [] }, + }); + }); + + it("keeps selected SDK GUI launchable while project package and API-key discovery are deferred", () => { + const merged = applyCursorSdkProbe(cliStatus, { + installed: false, + authState: "unknown", + models: [], + projectDiscoveryDeferred: true, + diagnosticCode: "package_missing", + }); + + expect(merged.installed).toBe(true); + expect(merged.capabilities.presentationModes).toEqual(["terminal", "gui"]); + expect(merged.presentationAuthStates).toEqual({ + terminal: "authenticated", + gui: "unknown", + }); + expect(merged.presentationAuthUsesProviderLogin).toEqual({ gui: false }); + expect(merged.capabilities.presentationCapabilities?.gui?.models).toEqual([ + { id: "cli-model", label: "CLI Model" }, + ]); + }); + + it("keeps a globally discovered SDK launchable for a project-shell API key", () => { + const merged = applyCursorSdkProbe(cliStatus, { + installed: true, + authState: "unknown", + models: [], + projectDiscoveryDeferred: true, + diagnosticCode: "auth_missing", + }); + + expect(agentStatusForPresentation(merged, "gui")).toMatchObject({ + installed: true, + authState: "unknown", + capabilities: { + models: [{ id: "cli-model", label: "CLI Model" }], + presentationMode: "gui", + }, + }); + }); + + it("uses the documented SDK Auto fallback for a project-local SDK-only install", () => { + const sdkOnly = applyCursorSdkProbe( + { + ...cliStatus, + installed: false, + authState: "unknown", + capabilities: { + ...cliStatus.capabilities, + models: [], + }, + }, + { + installed: false, + authState: "unknown", + models: [], + projectDiscoveryDeferred: true, + diagnosticCode: "package_missing", + }, + ); + + expect(sdkOnly).toMatchObject({ + installed: true, + authState: "unknown", + presentationAuthStates: { gui: "unknown" }, + presentationAuthUsesProviderLogin: { gui: false }, + capabilities: { + presentationMode: "gui", + presentationModes: ["gui"], + models: [{ id: "auto", label: "Auto" }], + }, + }); + expect(sdkOnly.loginCommand).toBeUndefined(); + expect(sdkOnly.executablePath).toBeUndefined(); + }); +}); diff --git a/src/supervisor/agents/cursor/sdkDetection.ts b/src/supervisor/agents/cursor/sdkDetection.ts new file mode 100644 index 000000000..a3f40b178 --- /dev/null +++ b/src/supervisor/agents/cursor/sdkDetection.ts @@ -0,0 +1,321 @@ +import { authStateForPresentation, capabilitiesForPresentation } from "@/shared/agentSelection"; +import type { AgentCapability, AgentStatus, AuthState } from "@/shared/contracts"; +import { detectProbeLocation, type AgentEnvContext } from "../base"; +import { cursorSdkGuiCapabilities, type CursorSdkModel } from "./sdkModels"; +import { + CURSOR_SDK_SESSION_PREFIX, + cursorSdkConfiguredPath, + type CursorStructuredRuntime, +} from "./structuredRuntime"; +import { + CursorSdkWorkerRpcError, + spawnCursorSdkWorker, + type CursorSdkWorkerClient, + type CursorSdkWorkerSpawnOptions, +} from "./sdkWorkerClient"; + +const NOT_INSTALLED_CODES = new Set([ + "configured_path_invalid", + "module_api_incompatible", + "module_load_failed", + "node_incompatible", + "package_invalid", + "package_missing", + "platform_helper_incompatible", + "platform_helper_missing", + "platform_unsupported", + "version_incompatible", +]); + +const MISSING_AUTH_CODES = new Set([ + "auth_invalid", + "auth_missing", + "401", + "403", + "unauthenticated", + "bad_api_key", + "bad_user_api_key", +]); + +export interface CursorSdkRuntimeProbe { + installed: boolean; + authState: AuthState; + models: CursorSdkModel[]; + /** + * The environment-wide probe cannot see project-local node_modules or + * project-shell credentials because it deliberately runs from home (native) + * or /tmp (WSL). When package or credential discovery may therefore differ + * at launch, keep the explicitly-selected SDK runtime selectable and let the + * session resolve it from the real project cwd. Invalid configured paths and + * credentials that were actually presented and rejected remain exact + * failures. + */ + projectDiscoveryDeferred?: boolean; + version?: string; + source?: string; + diagnosticCode?: string; + diagnosticMessage?: string; +} + +export interface CursorSdkDetectionDependencies { + spawnWorker?( + options: CursorSdkWorkerSpawnOptions, + ): Promise>; +} + +/** + * Merge independently detected CLI and SDK availability into one provider + * tile without conflating their credentials or model catalogs. + */ +export function applyCursorSdkProbe( + cliStatus: AgentStatus, + probe: CursorSdkRuntimeProbe, + selectedRuntime: CursorStructuredRuntime = "sdk", +): AgentStatus { + const cliInstalled = cliStatus.installed; + const sdkSelectable = probe.installed || probe.projectDiscoveryDeferred === true; + const sdkModels = + probe.models.length > 0 + ? probe.models + : probe.projectDiscoveryDeferred + ? deferredProjectSdkModels(cliStatus) + : []; + const sdkGui = cursorSdkGuiCapabilities(sdkModels); + const acpCapabilities = cursorAcpRuntimeCapabilities(cliStatus.capabilities); + const sdkCapabilities = cursorSdkRuntimeCapabilities( + cliStatus.capabilities, + sdkGui, + cliInstalled, + ); + const runtimeVariants: NonNullable = { + acp: { + presentationMode: "gui", + installed: cliInstalled, + ...(cliStatus.version ? { version: cliStatus.version } : {}), + authState: authStateForPresentation(cliStatus, "gui"), + authUsesProviderLogin: true, + capabilities: acpCapabilities, + }, + sdk: { + presentationMode: "gui", + installed: sdkSelectable, + ...(probe.version ? { version: probe.version } : {}), + ...(probe.source ? { installationSource: probe.source } : {}), + authState: probe.authState, + authUsesProviderLogin: false, + capabilities: sdkCapabilities, + }, + }; + const runtimeRouting: NonNullable = { + prefixes: { [CURSOR_SDK_SESSION_PREFIX]: "sdk" }, + fallbackRuntime: "acp", + }; + + if (selectedRuntime === "acp") { + const { presentationCapabilities: _presentationCapabilities, ...acpRootCapabilities } = + cliStatus.capabilities; + return { + ...cliStatus, + capabilities: { + ...acpRootCapabilities, + mcpScope: { + ...acpRootCapabilities.mcpScope, + gui: "launch", + }, + presentationCapabilities: { + gui: acpCapabilities, + }, + }, + runtimeVariants, + sessionRuntimeRouting: runtimeRouting, + }; + } + + const { presentationCapabilities: _cliPresentationCapabilities, ...cliCapabilities } = + cliStatus.capabilities; + const capabilities: AgentStatus["capabilities"] = cliInstalled + ? { + ...cliCapabilities, + presentationModes: sdkSelectable ? ["terminal", "gui"] : ["terminal"], + mcpScope: { + ...cliStatus.capabilities.mcpScope, + ...(sdkSelectable ? { gui: "always" as const } : {}), + }, + ...(sdkSelectable + ? { + presentationCapabilities: { + gui: sdkGui, + }, + } + : {}), + } + : { + ...cliCapabilities, + ...sdkGui, + presentationMode: "gui", + presentationModes: sdkSelectable ? ["gui"] : [], + supportsOneShot: false, + mcpScope: sdkSelectable ? ({ gui: "always" } as const) : {}, + ...(sdkSelectable ? { presentationCapabilities: { gui: sdkGui } } : {}), + }; + + const presentationAuthStates = { + ...(cliInstalled ? { terminal: cliStatus.authState } : {}), + ...(sdkSelectable ? { gui: probe.authState } : {}), + }; + const merged: AgentStatus = { + ...cliStatus, + installed: cliInstalled || sdkSelectable, + authState: cliInstalled ? cliStatus.authState : probe.authState, + capabilities, + ...(cliInstalled + ? {} + : { + ...(probe.version ? { version: probe.version } : {}), + }), + ...(Object.keys(presentationAuthStates).length > 0 ? { presentationAuthStates } : {}), + ...(sdkSelectable ? { presentationAuthUsesProviderLogin: { gui: false } } : {}), + runtimeVariants, + sessionRuntimeRouting: runtimeRouting, + }; + + if (cliInstalled) return merged; + const { + loginCommand: _loginCommand, + authMethods: _authMethods, + authLogoutSupported: _authLogoutSupported, + preferTerminalLogin: _preferTerminalLogin, + update: _update, + executablePath: _executablePath, + ...sdkOnly + } = merged; + return sdkOnly; +} + +function withoutPresentationCapabilities(capabilities: AgentCapability): AgentCapability { + const { presentationCapabilities: _presentationCapabilities, ...effective } = capabilities; + return effective; +} + +function cursorAcpRuntimeCapabilities(capabilities: AgentCapability): AgentCapability { + const effective = withoutPresentationCapabilities( + capabilitiesForPresentation(capabilities, "gui"), + ); + return { + ...effective, + runtimeLabel: "ACP", + liveInputMode: "server", + presentationMode: "gui", + mcpScope: { + ...effective.mcpScope, + gui: "launch", + }, + }; +} + +function cursorSdkRuntimeCapabilities( + capabilities: AgentCapability, + sdkGui: Partial, + cliInstalled: boolean, +): AgentCapability { + const { presentationCapabilities: _presentationCapabilities, ...base } = capabilities; + const effective = withoutPresentationCapabilities( + capabilitiesForPresentation( + { + ...base, + mcpScope: { + ...base.mcpScope, + gui: "always", + }, + presentationCapabilities: { gui: sdkGui }, + }, + "gui", + ), + ); + return { + ...effective, + supportsOneShot: cliInstalled ? effective.supportsOneShot : false, + }; +} + +/** + * Supply a launchable model choice while project-local SDK discovery is + * deferred. Cursor CLI and SDK model ids share the provider catalog. Preserve + * `auto` as the documented server-selected SDK fallback; `auto-smart` is the + * separately gated Cursor Router and must never be invented for an account + * whose catalog could not be read. The real catalog is fetched before + * Agent.create/resume and supplies any supported parameters. + */ +function deferredProjectSdkModels(cliStatus: AgentStatus): CursorSdkModel[] { + const models = cliStatus.capabilities.models.map((model) => ({ + id: model.id, + displayName: model.label, + })); + return models.length > 0 ? models : [{ id: "auto", displayName: "Auto" }]; +} + +/** + * Probe the external SDK from the environment where it will actually run. + * + * This is intentionally worker-backed even on the native host: importing the + * SDK into the supervisor would run its local agent loop in the app process, + * and importing a Linux optional package from Windows is impossible for WSL. + */ +export async function probeCursorSdkRuntime( + ctx: AgentEnvContext | undefined, + dependencies: CursorSdkDetectionDependencies = {}, +): Promise { + const projectLocation = detectProbeLocation(ctx); + const configuredPath = cursorSdkConfiguredPath(ctx?.agentSettings, projectLocation); + let worker: Pick | undefined; + try { + worker = await (dependencies.spawnWorker ?? spawnCursorSdkWorker)({ + projectLocation, + ...(configuredPath ? { configuredPath } : {}), + }); + const result = await worker.probe(); + return { + installed: true, + authState: "authenticated", + models: result.models, + version: result.sdkVersion, + source: result.source, + }; + } catch (error) { + const code = cursorSdkProbeErrorCode(error); + const normalizedCode = code?.toLowerCase(); + const projectDiscoveryDeferred = + (normalizedCode === "package_missing" && configuredPath === undefined) || + normalizedCode === "auth_missing"; + return { + // Once the helper booted, an unclassified failure is normally the + // account catalog request (network/service), not package discovery. + // Loader failures have stable codes and are classified above. + installed: normalizedCode ? !NOT_INSTALLED_CODES.has(normalizedCode) : worker !== undefined, + authState: + normalizedCode && MISSING_AUTH_CODES.has(normalizedCode) && !projectDiscoveryDeferred + ? "missing" + : "unknown", + models: [], + ...(projectDiscoveryDeferred ? { projectDiscoveryDeferred: true } : {}), + ...(code ? { diagnosticCode: code } : {}), + diagnosticMessage: cursorSdkProbeErrorMessage(error), + }; + } finally { + await worker?.dispose().catch(() => undefined); + } +} + +function cursorSdkProbeErrorCode(error: unknown): string | undefined { + if (error instanceof CursorSdkWorkerRpcError) return error.code; + if (error && typeof error === "object" && "code" in error) { + const code = (error as { code?: unknown }).code; + if (typeof code === "string" || typeof code === "number") return String(code); + } + return undefined; +} + +function cursorSdkProbeErrorMessage(error: unknown): string { + if (error instanceof Error && error.message.trim()) return error.message; + return "The Cursor SDK runtime probe failed."; +} diff --git a/src/supervisor/agents/cursor/sdkLoader.test.ts b/src/supervisor/agents/cursor/sdkLoader.test.ts new file mode 100644 index 000000000..08f6f01c4 --- /dev/null +++ b/src/supervisor/agents/cursor/sdkLoader.test.ts @@ -0,0 +1,482 @@ +import { mkdir, mkdtemp, realpath, rm, writeFile } from "node:fs/promises"; +import { delimiter, join } from "node:path"; +import { tmpdir } from "node:os"; +import { afterEach, describe, expect, it } from "vitest"; +import { loadCursorSdk } from "./sdkLoader"; +import { + classifyCursorSdkRuntimeError, + cursorSdkPlatformPackageName, + isSupportedCursorSdkNodeVersion, + isSupportedCursorSdkVersion, + type CursorSdkLoadResult, +} from "./sdkLoaderSupport"; +import type { CursorSdkLoadOptions } from "./sdkPackageDiscovery"; + +const TEST_PLATFORM: NodeJS.Platform = "linux"; +const TEST_ARCH = "x64"; +const TEST_HELPER = "@cursor/sdk-linux-x64"; +const TEST_SDK_VERSION = "1.0.24"; +const createdDirectories: string[] = []; + +interface FakePackageOptions { + version?: string; + packageName?: string; + helperVersion?: string; + installHelper?: boolean; + moduleSource?: string; + exportTarget?: string; +} + +interface FakePackage { + root: string; + nodeModulesRoot: string; + packageRoot: string; + entryPath: string; + helperRoot: string; +} + +const VALID_MODULE_SOURCE = ` +export class Agent { + static async create(options) { return { agentId: "new-agent", options }; } + static async resume(agentId, options) { return { agentId, options }; } +} +export class Cursor { + static models = { async list() { return []; } }; +} +`; + +afterEach(async () => { + await Promise.all( + createdDirectories + .splice(0) + .map((directory) => rm(directory, { recursive: true, force: true })), + ); +}); + +async function makeFakePackage(options: FakePackageOptions = {}): Promise { + const root = await realpath(await mkdtemp(join(tmpdir(), "poracode-cursor-sdk-loader-"))); + createdDirectories.push(root); + const nodeModulesRoot = join(root, "node_modules"); + const packageRoot = join(nodeModulesRoot, "@cursor", "sdk"); + const entryPath = join(packageRoot, "index.mjs"); + const helperRoot = join(nodeModulesRoot, "@cursor", "sdk-linux-x64"); + const version = options.version ?? TEST_SDK_VERSION; + const helperVersion = options.helperVersion ?? version; + + await mkdir(packageRoot, { recursive: true }); + await writeFile( + join(packageRoot, "package.json"), + JSON.stringify({ + name: options.packageName ?? "@cursor/sdk", + version, + type: "module", + exports: { + ".": { + import: options.exportTarget ?? "./index.mjs", + }, + }, + optionalDependencies: { + [TEST_HELPER]: version, + }, + }), + ); + await writeFile(entryPath, options.moduleSource ?? VALID_MODULE_SOURCE); + + if (options.installHelper !== false) { + await mkdir(helperRoot, { recursive: true }); + await writeFile( + join(helperRoot, "package.json"), + JSON.stringify({ name: TEST_HELPER, version: helperVersion }), + ); + } + + return { root, nodeModulesRoot, packageRoot, entryPath, helperRoot }; +} + +function baseOptions(overrides: CursorSdkLoadOptions = {}): CursorSdkLoadOptions { + return { + includeGlobal: false, + nodeVersion: "22.13.0", + platform: TEST_PLATFORM, + arch: TEST_ARCH, + apiKey: "test-key", + env: {}, + ...overrides, + }; +} + +function expectSuccess(result: CursorSdkLoadResult) { + expect(result.ok).toBe(true); + if (!result.ok) throw new Error(`Expected success, got ${result.diagnostic.code}`); + return result.value; +} + +describe("loadCursorSdk discovery", () => { + it("loads an explicitly configured package directory without a package dependency", async () => { + const fake = await makeFakePackage(); + + const loaded = expectSuccess( + await loadCursorSdk(baseOptions({ configuredPath: fake.packageRoot })), + ); + + expect(loaded.source).toBe("configured"); + expect(loaded.packageRoot).toBe(fake.packageRoot); + expect(loaded.entryPath).toBe(fake.entryPath); + expect(loaded.version).toBe(TEST_SDK_VERSION); + expect(loaded.platformHelper).toEqual({ + name: TEST_HELPER, + packageRoot: fake.helperRoot, + version: TEST_SDK_VERSION, + }); + expect(loaded.authSource).toBe("option"); + expect(loaded.module.Agent.create).toBeTypeOf("function"); + expect(loaded.module.Agent.resume).toBeTypeOf("function"); + expect(loaded.module.Cursor.models.list).toBeTypeOf("function"); + }); + + it("accepts a configured entry file and a configured node_modules root", async () => { + const fromEntry = await makeFakePackage(); + const fromModules = await makeFakePackage(); + + expect( + expectSuccess(await loadCursorSdk(baseOptions({ configuredPath: fromEntry.entryPath }))) + .packageRoot, + ).toBe(fromEntry.packageRoot); + expect( + expectSuccess( + await loadCursorSdk(baseOptions({ configuredPath: fromModules.nodeModulesRoot })), + ).packageRoot, + ).toBe(fromModules.packageRoot); + }); + + it("discovers the nearest project ancestor installation", async () => { + const fake = await makeFakePackage(); + const nestedProject = join(fake.root, "workspace", "packages", "app"); + await mkdir(nestedProject, { recursive: true }); + + const loaded = expectSuccess(await loadCursorSdk(baseOptions({ projectCwd: nestedProject }))); + + expect(loaded.source).toBe("project"); + expect(loaded.packageRoot).toBe(fake.packageRoot); + }); + + it("discovers NODE_PATH and explicit global installations", async () => { + const fromNodePath = await makeFakePackage(); + const fromExplicitRoot = await makeFakePackage(); + const emptyProject = await mkdtemp(join(tmpdir(), "poracode-cursor-empty-project-")); + createdDirectories.push(emptyProject); + + const nodePathLoaded = expectSuccess( + await loadCursorSdk( + baseOptions({ + projectCwd: emptyProject, + includeGlobal: true, + env: { NODE_PATH: `${fromNodePath.nodeModulesRoot}${delimiter}` }, + }), + { resolvePackageManagerRoots: async () => [] }, + ), + ); + expect(nodePathLoaded.source).toBe("node-path"); + + const explicitLoaded = expectSuccess( + await loadCursorSdk( + baseOptions({ + projectCwd: emptyProject, + includeGlobal: true, + env: {}, + globalPackageRoots: [fromExplicitRoot.nodeModulesRoot], + }), + { resolvePackageManagerRoots: async () => [] }, + ), + ); + expect(explicitLoaded.source).toBe("global-explicit"); + }); + + it("uses npm/pnpm global roots returned by the safe host probe", async () => { + const fake = await makeFakePackage(); + const emptyProject = await mkdtemp(join(tmpdir(), "poracode-cursor-empty-project-")); + createdDirectories.push(emptyProject); + + const loaded = expectSuccess( + await loadCursorSdk( + baseOptions({ + projectCwd: emptyProject, + includeGlobal: true, + env: {}, + }), + { + resolvePackageManagerRoots: async () => [ + { root: join(emptyProject, "missing"), source: "global-npm" }, + { root: fake.nodeModulesRoot, source: "global-pnpm" }, + ], + }, + ), + ); + + expect(loaded.source).toBe("global-pnpm"); + }); + + it("does not fall back when an explicit path points at another package", async () => { + const invalid = await makeFakePackage({ packageName: "cursor-agent" }); + const valid = await makeFakePackage(); + + const result = await loadCursorSdk( + baseOptions({ + configuredPath: invalid.packageRoot, + includeGlobal: true, + globalPackageRoots: [valid.nodeModulesRoot], + }), + { resolvePackageManagerRoots: async () => [] }, + ); + + expect(result).toMatchObject({ + ok: false, + diagnostic: { code: "configured_path_invalid" }, + }); + }); + + it("reports all checked locations when the package is missing", async () => { + const emptyProject = await mkdtemp(join(tmpdir(), "poracode-cursor-empty-project-")); + createdDirectories.push(emptyProject); + + const result = await loadCursorSdk(baseOptions({ projectCwd: emptyProject })); + + expect(result).toMatchObject({ + ok: false, + diagnostic: { + code: "package_missing", + details: { checkedPaths: expect.arrayContaining([]) }, + }, + }); + const checkedPaths = result.ok ? [] : result.diagnostic.details?.checkedPaths; + expect(checkedPaths).toContain(join(emptyProject, "node_modules", "@cursor", "sdk")); + }); +}); + +describe("loadCursorSdk compatibility checks", () => { + it("rejects Node versions older than 22.13 before package discovery", async () => { + const result = await loadCursorSdk(baseOptions({ nodeVersion: "v22.12.9" })); + expect(result).toMatchObject({ + ok: false, + diagnostic: { code: "node_incompatible" }, + }); + }); + + it("reports WSL as a cross-environment worker requirement", async () => { + const result = await loadCursorSdk( + baseOptions({ environment: { kind: "wsl", distro: "Ubuntu-24.04" } }), + ); + expect(result).toMatchObject({ + ok: false, + diagnostic: { + code: "cross_environment_unsupported", + details: { distro: "Ubuntu-24.04" }, + }, + }); + }); + + it("rejects platforms for which Cursor publishes no helper", async () => { + const result = await loadCursorSdk(baseOptions({ platform: "freebsd", arch: "riscv64" })); + expect(result).toMatchObject({ + ok: false, + diagnostic: { code: "platform_unsupported" }, + }); + }); + + it("rejects incompatible SDK major versions", async () => { + const fake = await makeFakePackage({ version: "2.0.0" }); + const result = await loadCursorSdk(baseOptions({ configuredPath: fake.packageRoot })); + expect(result).toMatchObject({ + ok: false, + diagnostic: { code: "version_incompatible" }, + }); + }); + + it("rejects SDK versions older than the audited local-agent API", async () => { + const fake = await makeFakePackage({ version: "1.0.23", helperVersion: "1.0.23" }); + const result = await loadCursorSdk(baseOptions({ configuredPath: fake.packageRoot })); + + expect(result).toEqual({ + ok: false, + diagnostic: { + code: "version_incompatible", + message: + "Cursor SDK 1.0.23 is not compatible with this integration; install version 1.0.24 or later in the stable 1.x series.", + recoverable: true, + details: { + detectedVersion: "1.0.23", + supportedRange: ">=1.0.24 <2.0.0", + }, + }, + }); + }); + + it("distinguishes missing and mismatched platform helpers", async () => { + const missing = await makeFakePackage({ installHelper: false }); + const mismatched = await makeFakePackage({ helperVersion: "1.0.23" }); + + const missingResult = await loadCursorSdk(baseOptions({ configuredPath: missing.packageRoot })); + expect(missingResult).toMatchObject({ + ok: false, + diagnostic: { code: "platform_helper_missing" }, + }); + + const mismatchResult = await loadCursorSdk( + baseOptions({ configuredPath: mismatched.packageRoot }), + ); + expect(mismatchResult).toMatchObject({ + ok: false, + diagnostic: { code: "platform_helper_incompatible" }, + }); + }); + + it("rejects entry points outside the package directory", async () => { + const fake = await makeFakePackage({ exportTarget: "../../../outside.mjs" }); + await writeFile(join(fake.root, "outside.mjs"), VALID_MODULE_SOURCE); + + const result = await loadCursorSdk(baseOptions({ configuredPath: fake.packageRoot })); + + expect(result).toMatchObject({ + ok: false, + diagnostic: { code: "package_invalid" }, + }); + }); + + it("validates only the stable public API essentials", async () => { + const fake = await makeFakePackage({ + moduleSource: ` + export class Agent { static async create() {} } + export class Cursor { static models = { async list() { return []; } }; } + `, + }); + + const result = await loadCursorSdk(baseOptions({ configuredPath: fake.packageRoot })); + + expect(result).toMatchObject({ + ok: false, + diagnostic: { + code: "module_api_incompatible", + details: { missingExports: ["Agent.resume"] }, + }, + }); + }); + + it("accepts the public API under a CommonJS-style default export", async () => { + const fake = await makeFakePackage(); + const Agent = { + create: async () => ({}), + resume: async () => ({}), + }; + const Cursor = { models: { list: async () => [] } }; + + const loaded = expectSuccess( + await loadCursorSdk(baseOptions({ configuredPath: fake.packageRoot }), { + importModule: async () => ({ default: { Agent, Cursor } }), + }), + ); + + expect(loaded.module.Agent).toBe(Agent); + expect(loaded.module.Cursor).toBe(Cursor); + }); + + it("distinguishes import failures from missing packages", async () => { + const fake = await makeFakePackage(); + + const result = await loadCursorSdk(baseOptions({ configuredPath: fake.packageRoot }), { + importModule: async () => { + throw Object.assign(new Error("broken transitive dependency"), { + code: "ERR_MODULE_NOT_FOUND", + }); + }, + }); + + expect(result).toMatchObject({ + ok: false, + diagnostic: { code: "module_load_failed" }, + }); + }); +}); + +describe("loadCursorSdk authentication", () => { + it("reports missing API-key auth after validating the installed SDK", async () => { + const fake = await makeFakePackage(); + + const result = await loadCursorSdk( + baseOptions({ + configuredPath: fake.packageRoot, + apiKey: " ", + env: {}, + }), + ); + + expect(result).toMatchObject({ + ok: false, + diagnostic: { code: "auth_missing" }, + }); + }); + + it("recognizes CURSOR_API_KEY without returning its value", async () => { + const fake = await makeFakePackage(); + const options = baseOptions({ + configuredPath: fake.packageRoot, + env: { CURSOR_API_KEY: "environment-secret" }, + }); + delete options.apiKey; + + const loaded = expectSuccess(await loadCursorSdk(options)); + + expect(loaded.authSource).toBe("environment"); + expect(JSON.stringify(loaded)).not.toContain("environment-secret"); + }); +}); + +describe("Cursor SDK version and runtime diagnostics", () => { + it("implements the documented Node >=22.13 boundary", () => { + expect(isSupportedCursorSdkNodeVersion("22.12.99")).toBe(false); + expect(isSupportedCursorSdkNodeVersion("v22.13.0")).toBe(true); + expect(isSupportedCursorSdkNodeVersion("22.13")).toBe(true); + expect(isSupportedCursorSdkNodeVersion("23.0.0")).toBe(true); + expect(isSupportedCursorSdkNodeVersion("not-a-version")).toBe(false); + }); + + it("accepts audited stable 1.x releases and rejects older, prerelease, and other majors", () => { + expect(isSupportedCursorSdkVersion("1.0.23")).toBe(false); + expect(isSupportedCursorSdkVersion("1.0.24")).toBe(true); + expect(isSupportedCursorSdkVersion("1.1.0")).toBe(true); + expect(isSupportedCursorSdkVersion("1.99.4")).toBe(true); + expect(isSupportedCursorSdkVersion("1.0.24-beta.1")).toBe(false); + expect(isSupportedCursorSdkVersion("0.99.0")).toBe(false); + expect(isSupportedCursorSdkVersion("2.0.0")).toBe(false); + }); + + it("maps only platform packages published by Cursor", () => { + expect(cursorSdkPlatformPackageName("darwin", "arm64")).toBe("@cursor/sdk-darwin-arm64"); + expect(cursorSdkPlatformPackageName("darwin", "x64")).toBe("@cursor/sdk-darwin-x64"); + expect(cursorSdkPlatformPackageName("linux", "arm64")).toBe("@cursor/sdk-linux-arm64"); + expect(cursorSdkPlatformPackageName("linux", "x64")).toBe("@cursor/sdk-linux-x64"); + expect(cursorSdkPlatformPackageName("win32", "x64")).toBe("@cursor/sdk-win32-x64"); + expect(cursorSdkPlatformPackageName("win32", "arm64")).toBeUndefined(); + }); + + it("classifies invalid auth and late platform-helper import failures", () => { + const auth = Object.assign(new Error("nope"), { + name: "AuthenticationError", + status: 401, + }); + expect(classifyCursorSdkRuntimeError(auth)).toMatchObject({ code: "auth_invalid" }); + for (const code of ["unauthenticated", "BAD_API_KEY", "BAD_USER_API_KEY"]) { + expect( + classifyCursorSdkRuntimeError(Object.assign(new Error("nope"), { code })), + ).toMatchObject({ code: "auth_invalid" }); + } + + const helper = Object.assign(new Error("Cannot find package '@cursor/sdk-linux-x64'"), { + code: "ERR_MODULE_NOT_FOUND", + }); + expect(classifyCursorSdkRuntimeError(helper)).toMatchObject({ + code: "platform_helper_missing", + details: { platformHelper: "@cursor/sdk-linux-x64" }, + }); + expect(classifyCursorSdkRuntimeError(new Error("network failed"))).toBeUndefined(); + }); +}); diff --git a/src/supervisor/agents/cursor/sdkLoader.ts b/src/supervisor/agents/cursor/sdkLoader.ts new file mode 100644 index 000000000..0f9c3ca6e --- /dev/null +++ b/src/supervisor/agents/cursor/sdkLoader.ts @@ -0,0 +1,140 @@ +import { pathToFileURL } from "node:url"; +import { + discoverCursorSdkPackage, + resolveCursorSdkPackageEntry, + resolveCursorSdkPlatformHelper, + type CursorSdkLoaderDependencies, + type CursorSdkLoadOptions, +} from "./sdkPackageDiscovery"; +import { + classifyCursorSdkRuntimeError, + cursorSdkFailure, + cursorSdkPlatformPackageName, + isSupportedCursorSdkNodeVersion, + isSupportedCursorSdkVersion, + resolveCursorSdkAuthSource, + safeCursorSdkErrorSummary, + validateCursorSdkModule, + type CursorSdkLoadResult, +} from "./sdkLoaderSupport"; + +export type { CursorSdkModule } from "./sdkLoaderSupport"; + +export async function loadCursorSdk( + options: CursorSdkLoadOptions = {}, + dependencies: CursorSdkLoaderDependencies = {}, +): Promise { + const environment = options.environment ?? { kind: "native" }; + if (environment.kind === "wsl") { + return cursorSdkFailure( + "cross_environment_unsupported", + "The Cursor SDK must run inside the target WSL distribution; it cannot be imported into the Windows supervisor process.", + true, + { distro: environment.distro }, + ); + } + + const nodeVersion = options.nodeVersion ?? process.versions.node; + if (!isSupportedCursorSdkNodeVersion(nodeVersion)) { + return cursorSdkFailure( + "node_incompatible", + "The Cursor SDK requires Node.js 22.13 or later.", + true, + { + detectedVersion: nodeVersion, + minimumVersion: "22.13.0", + }, + ); + } + + const platform = options.platform ?? process.platform; + const arch = options.arch ?? process.arch; + const platformHelperName = cursorSdkPlatformPackageName(platform, arch); + if (!platformHelperName) { + return cursorSdkFailure( + "platform_unsupported", + `The Cursor SDK does not publish a platform helper for ${platform}-${arch}.`, + false, + { platform, arch }, + ); + } + + const discovery = await discoverCursorSdkPackage(options, dependencies); + if ("diagnostic" in discovery) return { ok: false, diagnostic: discovery.diagnostic }; + const pkg = discovery; + + if (!isSupportedCursorSdkVersion(pkg.version)) { + return cursorSdkFailure( + "version_incompatible", + `Cursor SDK ${pkg.version} is not compatible with this integration; install version 1.0.24 or later in the stable 1.x series.`, + true, + { detectedVersion: pkg.version, supportedRange: ">=1.0.24 <2.0.0" }, + ); + } + + const entry = await resolveCursorSdkPackageEntry(pkg); + if ("diagnostic" in entry) return { ok: false, diagnostic: entry.diagnostic }; + + const helper = await resolveCursorSdkPlatformHelper(pkg, platformHelperName); + if ("diagnostic" in helper) return { ok: false, diagnostic: helper.diagnostic }; + + let imported: unknown; + try { + imported = await (dependencies.importModule ?? importExternalModule)( + pathToFileURL(entry.entryPath).href, + ); + } catch (error) { + const classified = classifyCursorSdkRuntimeError(error); + if (classified?.code === "platform_helper_missing") { + return { ok: false, diagnostic: classified }; + } + return cursorSdkFailure( + "module_load_failed", + "The installed Cursor SDK could not be imported.", + true, + { + reason: safeCursorSdkErrorSummary(error), + entryPath: entry.entryPath, + }, + ); + } + + const validatedModule = validateCursorSdkModule(imported); + if (!validatedModule.ok) { + return cursorSdkFailure( + "module_api_incompatible", + "The installed package does not expose the expected public Cursor SDK API.", + true, + { missingExports: validatedModule.missing }, + ); + } + + const env = options.env ?? process.env; + const authSource = resolveCursorSdkAuthSource(options.apiKey, env); + if (!authSource) { + return cursorSdkFailure( + "auth_missing", + "The Cursor SDK requires an API key; the installed cursor-agent login cannot be reused.", + true, + { environmentVariable: "CURSOR_API_KEY" }, + ); + } + + return { + ok: true, + value: { + module: validatedModule.module, + packageRoot: pkg.packageRoot, + packageJsonPath: pkg.packageJsonPath, + entryPath: entry.entryPath, + version: pkg.version, + source: pkg.source, + platformHelper: helper, + authSource, + }, + }; +} + +async function importExternalModule(specifier: string): Promise { + return import(specifier); +} diff --git a/src/supervisor/agents/cursor/sdkLoaderSupport.ts b/src/supervisor/agents/cursor/sdkLoaderSupport.ts new file mode 100644 index 000000000..c29763f3a --- /dev/null +++ b/src/supervisor/agents/cursor/sdkLoaderSupport.ts @@ -0,0 +1,244 @@ +import { compareVersions } from "@/shared/changelog"; + +const CURSOR_SDK_MIN_NODE = [22, 13, 0] as const; +const CURSOR_SDK_MIN_VERSION = [1, 0, 24] as const; +const CURSOR_SDK_SUPPORTED_MAJOR = 1; + +export type CursorSdkDiagnosticCode = + | "auth_invalid" + | "auth_missing" + | "configured_path_invalid" + | "cross_environment_unsupported" + | "module_api_incompatible" + | "module_load_failed" + | "node_incompatible" + | "package_invalid" + | "package_missing" + | "platform_helper_incompatible" + | "platform_helper_missing" + | "platform_unsupported" + | "version_incompatible"; + +export interface CursorSdkDiagnostic { + code: CursorSdkDiagnosticCode; + message: string; + recoverable: boolean; + details?: Readonly>; +} + +export type CursorSdkPackageSource = + | "configured" + | "project" + | "node-path" + | "global-explicit" + | "global-inferred" + | "global-npm" + | "global-pnpm"; + +export type CursorSdkAuthSource = "option" | "environment"; + +export type CursorSdkExecutionEnvironment = { kind: "native" } | { kind: "wsl"; distro: string }; + +/** + * Deliberately structural: Poracode does not depend on or bundle + * `@cursor/sdk`. The session adapter can narrow additional SDK features at + * its own boundary while the loader verifies the stable entry points it + * needs to start or resume a local agent. + */ +export interface CursorSdkModule { + readonly Agent: CursorSdkAgentApi; + readonly Cursor: CursorSdkNamespaceApi; + readonly [name: string]: unknown; +} + +export interface CursorSdkAgentApi { + create(options: unknown): Promise; + resume(agentId: string, options?: unknown): Promise; + readonly [name: string]: unknown; +} + +export interface CursorSdkNamespaceApi { + readonly models: { + list(options?: unknown): Promise; + readonly [name: string]: unknown; + }; + readonly [name: string]: unknown; +} + +export interface CursorSdkLoadedPackage { + module: CursorSdkModule; + packageRoot: string; + packageJsonPath: string; + entryPath: string; + version: string; + source: CursorSdkPackageSource; + platformHelper: { + name: string; + packageRoot: string; + version: string; + }; + authSource: CursorSdkAuthSource; +} + +export type CursorSdkLoadResult = + | { ok: true; value: CursorSdkLoadedPackage } + | { ok: false; diagnostic: CursorSdkDiagnostic }; + +export function cursorSdkPlatformPackageName( + platform: NodeJS.Platform, + arch: string, +): string | undefined { + if (platform === "darwin" && (arch === "arm64" || arch === "x64")) { + return `@cursor/sdk-darwin-${arch}`; + } + if (platform === "linux" && (arch === "arm64" || arch === "x64")) { + return `@cursor/sdk-linux-${arch}`; + } + if (platform === "win32" && arch === "x64") return "@cursor/sdk-win32-x64"; + return undefined; +} + +export function isSupportedCursorSdkNodeVersion(version: string): boolean { + const parsed = parseCursorSdkVersion(version); + if (!parsed) return false; + return compareVersion(parsed, CURSOR_SDK_MIN_NODE) >= 0; +} + +export function isSupportedCursorSdkVersion(version: string): boolean { + const parsed = parseCursorSdkVersion(version); + return ( + parsed !== undefined && + parsed[0] === CURSOR_SDK_SUPPORTED_MAJOR && + compareVersion(parsed, CURSOR_SDK_MIN_VERSION) >= 0 && + !version.includes("-") + ); +} + +export function classifyCursorSdkRuntimeError(error: unknown): CursorSdkDiagnostic | undefined { + const record = isCursorSdkObjectLike(error) ? error : undefined; + const name = typeof record?.name === "string" ? record.name : ""; + const code = typeof record?.code === "string" ? record.code : ""; + const status = typeof record?.status === "number" ? record.status : undefined; + const message = error instanceof Error ? error.message : String(error); + + if ( + name === "AuthenticationError" || + status === 401 || + /(?:unauthenticated|(?:bad|invalid)[_ -]?(?:user[_ -]?)?api[_ -]?key|authentication)/i.test( + code, + ) + ) { + return { + code: "auth_invalid", + message: "Cursor rejected the configured SDK API key.", + recoverable: true, + }; + } + + const helper = message.match(/@cursor\/sdk-(?:darwin|linux|win32)-(?:arm64|x64)/)?.[0]; + if ( + helper && + (code === "ERR_MODULE_NOT_FOUND" || + code === "MODULE_NOT_FOUND" || + /(?:cannot find|could not resolve|missing)/i.test(message)) + ) { + return { + code: "platform_helper_missing", + message: `The Cursor SDK platform helper ${helper} is missing.`, + recoverable: true, + details: { platformHelper: helper }, + }; + } + + return undefined; +} + +export function cursorSdkFailure( + code: CursorSdkDiagnosticCode, + message: string, + recoverable: boolean, + details?: Readonly>, +): { ok: false; diagnostic: CursorSdkDiagnostic } { + return { + ok: false, + diagnostic: { + code, + message, + recoverable, + ...(details ? { details } : {}), + }, + }; +} + +export function validateCursorSdkModule( + imported: unknown, +): { ok: true; module: CursorSdkModule } | { ok: false; missing: string[] } { + const direct = isCursorSdkObjectLike(imported) ? imported : undefined; + const fallback = direct && isCursorSdkObjectLike(direct.default) ? direct.default : undefined; + const candidate = + direct && hasCursorSdkCoreShape(direct) + ? direct + : fallback && hasCursorSdkCoreShape(fallback) + ? fallback + : (direct ?? fallback); + const missing: string[] = []; + + const agent = candidate && isCursorSdkObjectLike(candidate.Agent) ? candidate.Agent : undefined; + if (!agent || typeof agent.create !== "function") missing.push("Agent.create"); + if (!agent || typeof agent.resume !== "function") missing.push("Agent.resume"); + + const cursor = + candidate && isCursorSdkObjectLike(candidate.Cursor) ? candidate.Cursor : undefined; + const models = cursor && isCursorSdkObjectLike(cursor.models) ? cursor.models : undefined; + if (!models || typeof models.list !== "function") missing.push("Cursor.models.list"); + + return missing.length === 0 + ? { ok: true, module: candidate as unknown as CursorSdkModule } + : { ok: false, missing }; +} + +export function resolveCursorSdkAuthSource( + apiKey: string | undefined, + env: Readonly>, +): CursorSdkAuthSource | undefined { + if (apiKey?.trim()) return "option"; + if (env.CURSOR_API_KEY?.trim()) return "environment"; + return undefined; +} + +export function parseCursorSdkVersion(version: string): [number, number, number] | undefined { + const match = /^v?(\d+)\.(\d+)(?:\.(\d+))?(?:-[0-9A-Za-z.-]+)?$/.exec(version.trim()); + if (!match) return undefined; + return [Number(match[1]), Number(match[2]), Number(match[3] ?? 0)]; +} + +export function isCursorSdkObjectLike(value: unknown): value is Record { + return (typeof value === "object" && value !== null) || typeof value === "function"; +} + +export function safeCursorSdkErrorSummary(error: unknown): string { + const message = error instanceof Error ? error.message : String(error); + return message.length > 500 ? `${message.slice(0, 497)}...` : message; +} + +/** + * `parseCursorSdkVersion` already rejects malformed input, so the ordering + * itself can reuse the shared numeric-segment comparison. + */ +function compareVersion( + left: readonly [number, number, number], + right: readonly [number, number, number], +): number { + return compareVersions(left.join("."), right.join(".")); +} + +function hasCursorSdkCoreShape(value: Record): boolean { + const agent = isCursorSdkObjectLike(value.Agent) ? value.Agent : undefined; + const cursor = isCursorSdkObjectLike(value.Cursor) ? value.Cursor : undefined; + const models = cursor && isCursorSdkObjectLike(cursor.models) ? cursor.models : undefined; + return ( + typeof agent?.create === "function" && + typeof agent.resume === "function" && + typeof models?.list === "function" + ); +} diff --git a/src/supervisor/agents/cursor/sdkModels.test.ts b/src/supervisor/agents/cursor/sdkModels.test.ts new file mode 100644 index 000000000..c33412dfc --- /dev/null +++ b/src/supervisor/agents/cursor/sdkModels.test.ts @@ -0,0 +1,277 @@ +import { describe, expect, it } from "vitest"; +import { + buildCursorSdkModelSelection, + cursorSdkCapabilitiesFromModels, + cursorSdkGuiCapabilities, +} from "./sdkModels"; + +const catalog = [ + { + id: "composer-2.5", + displayName: "Composer 2.5", + parameters: [ + { + id: "effort", + values: [{ value: "low" }, { value: "medium" }, { value: "high" }], + }, + { + id: "context", + values: [ + { value: "200k", displayName: "200K" }, + { value: "1m", displayName: "1M" }, + ], + }, + { id: "fast", values: [{ value: "false" }, { value: "true" }] }, + { id: "thinking", values: [{ value: "false" }, { value: "true" }] }, + ], + variants: [ + { + displayName: "Fast", + params: [{ id: "fast", value: "true" }], + }, + { + displayName: "Deep Fast", + params: [ + { id: "effort", value: "high" }, + { id: "context", value: "1m" }, + { id: "fast", value: "true" }, + { id: "thinking", value: "true" }, + ], + }, + ], + }, + { + id: "auto-smart", + displayName: "Cursor Router", + parameters: [ + { + id: "optimize_for", + values: [ + { value: "cost", displayName: "Cost" }, + { value: "balanced", displayName: "Balance" }, + { value: "intelligence", displayName: "Intelligence" }, + ], + }, + ], + variants: [ + { + displayName: "Intelligence", + params: [{ id: "optimize_for", value: "intelligence" }], + }, + ], + }, +] as const; + +describe("buildCursorSdkModelSelection", () => { + it("maps generic composer controls to catalog-supported SDK parameters", () => { + expect( + buildCursorSdkModelSelection( + { + model: "composer-2.5", + effort: "high", + contextSize: "1m", + fast: true, + }, + catalog, + ), + ).toEqual({ + id: "composer-2.5", + params: [ + { id: "effort", value: "high" }, + { id: "context", value: "1m" }, + { id: "fast", value: "true" }, + ], + }); + }); + + it("preserves arbitrary bracket parameters and lets explicit controls override them", () => { + expect( + buildCursorSdkModelSelection( + { + model: "composer-2.5[future_mode=deep,effort=low]", + effort: "medium", + fast: false, + }, + catalog, + ), + ).toEqual({ + id: "composer-2.5", + params: [ + { id: "future_mode", value: "deep" }, + { id: "effort", value: "medium" }, + { id: "fast", value: "false" }, + ], + }); + }); + + it("keeps an advertised named preset authoritative over injected composer defaults", () => { + expect( + buildCursorSdkModelSelection( + { + model: "composer-2.5[effort=high,context=1m,fast=true,thinking=true]", + effort: "medium", + contextSize: "200k", + fast: false, + thinking: false, + }, + catalog, + ), + ).toEqual({ + id: "composer-2.5", + params: [ + { id: "effort", value: "high" }, + { id: "context", value: "1m" }, + { id: "fast", value: "true" }, + { id: "thinking", value: "true" }, + ], + }); + }); + + it("preserves explicit controls that a named preset does not fix", () => { + expect( + buildCursorSdkModelSelection( + { + model: "composer-2.5[fast=true]", + effort: "high", + contextSize: "1m", + fast: false, + thinking: true, + }, + catalog, + ), + ).toEqual({ + id: "composer-2.5", + params: [ + { id: "fast", value: "true" }, + { id: "effort", value: "high" }, + { id: "context", value: "1m" }, + { id: "thinking", value: "true" }, + ], + }); + }); + + it("defaults Cursor Router to Balance and preserves an explicit preset", () => { + expect(buildCursorSdkModelSelection({ model: "auto-smart" }, catalog)).toEqual({ + id: "auto-smart", + params: [{ id: "optimize_for", value: "balanced" }], + }); + expect( + buildCursorSdkModelSelection({ model: "auto-smart[optimize_for=intelligence]" }, catalog), + ).toEqual({ + id: "auto-smart", + params: [{ id: "optimize_for", value: "intelligence" }], + }); + expect(buildCursorSdkModelSelection({ model: "auto-smart" }, [])).toEqual({ + id: "auto-smart", + params: [{ id: "optimize_for", value: "balanced" }], + }); + expect( + buildCursorSdkModelSelection({ model: "auto-smart[optimize_for=intelligence]" }, []), + ).toEqual({ + id: "auto-smart", + params: [{ id: "optimize_for", value: "intelligence" }], + }); + expect( + buildCursorSdkModelSelection({ model: "future[future_mode=deep,effort=high]" }, []), + ).toEqual({ + id: "future", + params: [ + { id: "future_mode", value: "deep" }, + { id: "effort", value: "high" }, + ], + }); + }); + + it("drops known parameters whose values are no longer accepted", () => { + expect( + buildCursorSdkModelSelection( + { model: "composer-2.5[effort=retired]", effort: "xhigh", contextSize: "9m" }, + catalog, + ), + ).toEqual({ id: "composer-2.5" }); + }); + + it("falls back to a real catalog model when a deferred or retired id is invalid", () => { + expect( + buildCursorSdkModelSelection({ model: "retired[future_mode=unsafe]", effort: "high" }, [ + { id: "auto", displayName: "Auto" }, + ...catalog, + ]), + ).toEqual({ id: "auto" }); + expect(buildCursorSdkModelSelection({ model: "retired" }, catalog)).toEqual({ + id: "composer-2.5", + }); + }); +}); + +describe("cursorSdkCapabilitiesFromModels", () => { + it("uses controls for known parameter ranges and rows for non-generic SDK presets", () => { + expect(cursorSdkCapabilitiesFromModels(catalog)).toEqual({ + models: [ + { id: "composer-2.5", label: "Composer 2.5" }, + { id: "auto-smart", label: "Cursor Router" }, + { + id: "auto-smart[optimize_for=intelligence]", + label: "Cursor Router · Intelligence", + }, + ], + subProviders: [ + { id: "cursor", label: "Cursor Models" }, + { id: "other", label: "Other models" }, + ], + modelSubProvider: { + "composer-2.5": "cursor", + "auto-smart": "cursor", + "auto-smart[optimize_for=intelligence]": "cursor", + }, + efforts: ["low", "medium", "high"], + defaultEffort: "medium", + modelEfforts: { + "composer-2.5": ["low", "medium", "high"], + "auto-smart": [], + "auto-smart[optimize_for=intelligence]": [], + }, + contextSizes: [ + { id: "200k", label: "200K" }, + { id: "1m", label: "1M" }, + ], + modelContextSizes: { + "composer-2.5": ["200k", "1m"], + }, + fastModels: ["composer-2.5"], + thinkingModels: ["composer-2.5"], + }); + }); +}); + +describe("cursorSdkGuiCapabilities", () => { + it("advertises the SDK's real mode, Auto-review, sandbox, and resume controls", () => { + const capabilities = cursorSdkGuiCapabilities([ + { id: "composer-2.5", displayName: "Composer 2.5" }, + ]); + + expect(capabilities).toMatchObject({ + models: [{ id: "composer-2.5", label: "Composer 2.5" }], + runtimeLabel: "SDK", + modes: ["agent", "plan"], + approvalPolicies: [ + { id: "default", label: "Auto-review" }, + { id: "never", label: "Allow All Tools" }, + ], + sandboxModes: [ + { id: "workspace-write", label: "Workspace Sandbox" }, + { id: "danger-full-access", label: "No Sandbox" }, + ], + defaultApprovalPolicy: "default", + defaultSandboxMode: "workspace-write", + bypassPermissions: { + approvalPolicy: "never", + sandboxMode: "danger-full-access", + }, + supportsResume: true, + supportsDirectInput: false, + liveInputMode: "server", + presentationMode: "gui", + }); + }); +}); diff --git a/src/supervisor/agents/cursor/sdkModels.ts b/src/supervisor/agents/cursor/sdkModels.ts new file mode 100644 index 000000000..06145f4a4 --- /dev/null +++ b/src/supervisor/agents/cursor/sdkModels.ts @@ -0,0 +1,307 @@ +import type { AgentCapability, ThreadConfig } from "@/shared/contracts"; +import { parseBracketParams, stripBracketParams } from "@/shared/modelLabels"; +import { cursorModelGrouping } from "./modelGrouping"; + +export interface CursorSdkModelParameter { + id: string; + displayName?: string; + values: ReadonlyArray<{ value: string; displayName?: string }>; +} + +export interface CursorSdkModelVariant { + params: ReadonlyArray<{ id: string; value: string }>; + displayName: string; + description?: string; + isDefault?: boolean; +} + +export interface CursorSdkModel { + id: string; + displayName: string; + description?: string; + aliases?: readonly string[]; + parameters?: readonly CursorSdkModelParameter[]; + variants?: readonly CursorSdkModelVariant[]; +} + +export interface CursorSdkModelSelection { + id: string; + params?: Array<{ id: string; value: string }>; +} + +const SAFE_PARAM_TOKEN = /^[^,[\]=]+$/u; +const EFFORT_PARAM_IDS = ["reasoning", "effort"] as const; +const CONTEXT_PARAM_IDS = ["context", "context_size"] as const; +const GENERIC_CONTROL_PARAM_IDS = new Set([ + ...EFFORT_PARAM_IDS, + ...CONTEXT_PARAM_IDS, + "fast", + "thinking", +]); +function parameter(model: CursorSdkModel | undefined, ids: readonly string[]) { + return model?.parameters?.find((candidate) => ids.includes(candidate.id)); +} + +function accepts(param: CursorSdkModelParameter | undefined, value: string): boolean { + return param?.values.some((candidate) => candidate.value === value) === true; +} + +function setParam( + output: Map, + param: CursorSdkModelParameter | undefined, + value: string | undefined, + fixedParamIds?: ReadonlySet, +): void { + if (param && value !== undefined && !fixedParamIds?.has(param.id) && accepts(param, value)) { + output.set(param.id, value); + } +} + +/** + * Convert Poracode's fixed composer controls plus Cursor's bracket-encoded + * variant ids into the SDK's open-ended `{ id, params }` representation. + * Unknown/retired values are dropped against the live model catalog instead + * of sending a parameter Cursor no longer accepts. + */ +export function buildCursorSdkModelSelection( + config: Pick, + catalog: readonly CursorSdkModel[], +): CursorSdkModelSelection { + const requestedId = stripBracketParams(config.model); + const requestedModel = catalog.find( + (candidate) => + candidate.id === requestedId || candidate.aliases?.includes(requestedId) === true, + ); + // Detection may have supplied a deferred project-local catalog, or a saved + // draft may name a model retired since the previous launch. Once the real + // account catalog is available, never send a known-invalid id: prefer the + // SDK's documented Auto fallback, then the account's first model. + const model = + requestedModel ?? + (catalog.length > 0 + ? (catalog.find((candidate) => candidate.id === "auto") ?? catalog[0]) + : undefined); + const id = model?.id ?? requestedId; + const output = new Map(); + const selectedVariant = requestedModel?.variants?.find( + (variant) => variantModelId(requestedModel.id, variant) === config.model, + ); + const fixedParamIds = new Set(selectedVariant?.params.map(({ id: paramId }) => paramId)); + + // A resumed local agent can remain usable while the account catalog is + // temporarily unavailable. In that case its persisted bracket parameters + // are the only self-describing model selection we have, so preserve safe + // tokens verbatim. Once a non-empty catalog is available, only parameters + // from a model that actually resolved are eligible; this keeps retired + // model ids from leaking their parameters onto the Auto fallback. + const persistedParams = + requestedModel || catalog.length === 0 ? parseBracketParams(config.model) : {}; + for (const [paramId, value] of Object.entries(persistedParams)) { + const definition = model?.parameters?.find((candidate) => candidate.id === paramId); + if ( + definition + ? accepts(definition, value) + : SAFE_PARAM_TOKEN.test(paramId) && SAFE_PARAM_TOKEN.test(value) + ) { + output.set(paramId, value); + } + } + + setParam(output, parameter(model, EFFORT_PARAM_IDS), config.effort || undefined, fixedParamIds); + setParam( + output, + parameter(model, CONTEXT_PARAM_IDS), + config.contextSize && config.contextSize !== "default" ? config.contextSize : undefined, + fixedParamIds, + ); + + const fast = parameter(model, ["fast"]); + if (fast && config.fast !== undefined) setParam(output, fast, String(config.fast), fixedParamIds); + const thinking = parameter(model, ["thinking"]); + if (thinking && config.thinking !== undefined) + setParam(output, thinking, String(config.thinking), fixedParamIds); + + // Cursor's canonical docs require an explicit Router objective. Poracode's + // generic controls have no arbitrary-parameter picker, so Balance is the + // stable default unless a catalog variant encoded another value in the id. + // Keep this invariant during a resumed thread's temporary catalog outage: + // `auto-smart` without `optimize_for` is not a supported SDK contract. + const optimizeFor = parameter(model, ["optimize_for"]); + if (id === "auto-smart" && !output.has("optimize_for")) { + const preferred = optimizeFor + ? ["balanced", "intelligence", "cost"].find((value) => accepts(optimizeFor, value)) + : "balanced"; + if (preferred) output.set("optimize_for", preferred); + } + + const params = [...output].map(([paramId, value]) => ({ id: paramId, value })); + return { id, ...(params.length > 0 ? { params } : {}) }; +} + +function variantModelId(modelId: string, variant: CursorSdkModelVariant): string | undefined { + if (variant.params.length === 0) return undefined; + if ( + variant.params.some( + ({ id, value }) => !SAFE_PARAM_TOKEN.test(id) || !SAFE_PARAM_TOKEN.test(value), + ) + ) { + return undefined; + } + return `${modelId}[${variant.params.map(({ id, value }) => `${id}=${value}`).join(",")}]`; +} + +function needsDedicatedVariantRow(variant: CursorSdkModelVariant): boolean { + return variant.params.some(({ id }) => !GENERIC_CONTROL_PARAM_IDS.has(id)); +} + +/** + * Project the account-specific SDK catalog into Poracode's generic picker. + * Known parameter families become ordinary controls; named presets are also + * retained as bracket-encoded model rows so Router and future arbitrary SDK + * parameters remain selectable without adding provider fields to ThreadConfig. + */ +export function cursorSdkCapabilitiesFromModels( + catalog: readonly CursorSdkModel[], +): Pick< + AgentCapability, + | "models" + | "efforts" + | "defaultEffort" + | "modelEfforts" + | "subProviders" + | "modelSubProvider" + | "contextSizes" + | "modelContextSizes" + | "defaultContextSize" + | "fastModels" + | "thinkingModels" +> { + const models: AgentCapability["models"] = []; + const effortSet = new Set(); + const contextLabels = new Map(); + const modelEfforts: Record = {}; + const modelContextSizes: Record = {}; + const fastModels: string[] = []; + const thinkingModels: string[] = []; + + for (const model of catalog) { + models.push({ + id: model.id, + label: model.displayName, + ...(model.description ? { tooltipDescription: model.description } : {}), + }); + const variants: Array<{ id: string; variant: CursorSdkModelVariant }> = []; + for (const variant of model.variants ?? []) { + // Effort, context, Fast, and thinking are already first-class composer + // controls. A second row for every combination makes one SDK model look + // like many unrelated models. Keep only presets with parameters that the + // generic controls cannot represent (for example Router optimize_for). + if (!needsDedicatedVariantRow(variant)) continue; + const id = variantModelId(model.id, variant); + if (id) { + models.push({ + id, + label: `${model.displayName} · ${variant.displayName}`, + ...(variant.description || model.description + ? { tooltipDescription: variant.description ?? model.description! } + : {}), + }); + variants.push({ id, variant }); + } + } + + const effort = parameter(model, EFFORT_PARAM_IDS); + const efforts = effort?.values.map(({ value }) => value) ?? []; + modelEfforts[model.id] = efforts; + for (const { id, variant } of variants) { + const fixedEffort = variantParamValue(variant, EFFORT_PARAM_IDS); + modelEfforts[id] = fixedEffort && accepts(effort, fixedEffort) ? [fixedEffort] : efforts; + } + efforts.forEach((value) => effortSet.add(value)); + + const context = parameter(model, CONTEXT_PARAM_IDS); + const contexts = context?.values.map(({ value, displayName }) => { + contextLabels.set(value, displayName ?? value.toUpperCase()); + return value; + }); + if (contexts && contexts.length > 0) { + modelContextSizes[model.id] = contexts; + for (const { id, variant } of variants) { + const fixedContext = variantParamValue(variant, CONTEXT_PARAM_IDS); + modelContextSizes[id] = + fixedContext && accepts(context, fixedContext) ? [fixedContext] : contexts; + } + } + + if (accepts(parameter(model, ["fast"]), "true")) { + fastModels.push(model.id); + for (const { id, variant } of variants) { + if (variantParamValue(variant, ["fast"]) === undefined) fastModels.push(id); + } + } + if (accepts(parameter(model, ["thinking"]), "true")) { + thinkingModels.push(model.id); + for (const { id, variant } of variants) { + if (variantParamValue(variant, ["thinking"]) === undefined) thinkingModels.push(id); + } + } + } + + const efforts = [...effortSet]; + const contextSizes = [...contextLabels].map(([id, label]) => ({ id, label })); + return { + models, + ...cursorModelGrouping(models), + efforts, + ...(efforts.includes("medium") ? { defaultEffort: "medium" } : {}), + modelEfforts, + ...(contextSizes.length > 0 ? { contextSizes } : {}), + ...(Object.keys(modelContextSizes).length > 0 ? { modelContextSizes } : {}), + ...(contextSizes.some(({ id }) => id === "default") ? { defaultContextSize: "default" } : {}), + ...(fastModels.length > 0 ? { fastModels } : {}), + ...(thinkingModels.length > 0 ? { thinkingModels } : {}), + }; +} + +function variantParamValue( + variant: CursorSdkModelVariant, + ids: readonly string[], +): string | undefined { + return variant.params.find(({ id }) => ids.includes(id))?.value; +} + +/** + * Complete GUI capability projection for the local Cursor SDK runtime. + * + * SDK runs are headless: there is no interactive permission request. The + * `default` approval id therefore means Cursor Auto-review, while `never` + * preserves Poracode's provider-neutral full-access preset. The sandbox is a + * separate, enforceable boundary and reuses the shared Codex-compatible ids. + */ +export function cursorSdkGuiCapabilities( + catalog: readonly CursorSdkModel[], +): Partial { + return { + ...cursorSdkCapabilitiesFromModels(catalog), + runtimeLabel: "SDK", + modes: ["agent", "plan"], + approvalPolicies: [ + { id: "default", label: "Auto-review" }, + { id: "never", label: "Allow All Tools" }, + ], + sandboxModes: [ + { id: "workspace-write", label: "Workspace Sandbox" }, + { id: "danger-full-access", label: "No Sandbox" }, + ], + defaultApprovalPolicy: "default", + defaultSandboxMode: "workspace-write", + bypassPermissions: { + approvalPolicy: "never", + sandboxMode: "danger-full-access", + }, + supportsResume: true, + supportsDirectInput: false, + liveInputMode: "server", + presentationMode: "gui", + }; +} diff --git a/src/supervisor/agents/cursor/sdkPackageDiscovery.ts b/src/supervisor/agents/cursor/sdkPackageDiscovery.ts new file mode 100644 index 000000000..8219c22c7 --- /dev/null +++ b/src/supervisor/agents/cursor/sdkPackageDiscovery.ts @@ -0,0 +1,557 @@ +import { execFile } from "node:child_process"; +import { constants as fsConstants } from "node:fs"; +import { access, readFile, realpath, stat } from "node:fs/promises"; +import { createRequire } from "node:module"; +import { basename, delimiter, dirname, isAbsolute, join, relative, resolve, sep } from "node:path"; +import { + isCursorSdkObjectLike, + parseCursorSdkVersion, + type CursorSdkDiagnostic, + type CursorSdkExecutionEnvironment, + type CursorSdkPackageSource, +} from "./sdkLoaderSupport"; + +const CURSOR_SDK_PACKAGE_NAME = "@cursor/sdk"; +const PACKAGE_JSON_MAX_BYTES = 1024 * 1024; +const GLOBAL_ROOT_PROBE_TIMEOUT_MS = 5_000; +const GLOBAL_ROOT_PROBE_MAX_BYTES = 64 * 1024; + +export type CursorSdkGlobalRootSource = "global-npm" | "global-pnpm"; + +export interface CursorSdkGlobalPackageRoot { + root: string; + source: CursorSdkGlobalRootSource; +} + +export interface CursorSdkLoadOptions { + /** + * An explicit `@cursor/sdk` package directory, its package.json/entry file, + * a node_modules directory, or a directory containing node_modules. + * Explicit configuration is authoritative: an invalid path does not + * silently fall back to another installed copy. + */ + configuredPath?: string; + /** Native project directory used for normal Node ancestor discovery. */ + projectCwd?: string; + /** Defaults to the current process environment. */ + environment?: CursorSdkExecutionEnvironment; + /** + * API key supplied to SDK calls by the session adapter. It is only checked + * for presence here and is never returned from the loader result. + */ + apiKey?: string; + /** Defaults to `process.env`; injectable so callers can use provider env. */ + env?: Readonly>; + /** Additional global node_modules roots, ahead of inferred npm/pnpm roots. */ + globalPackageRoots?: readonly string[]; + /** Disable NODE_PATH and global npm/pnpm discovery. Primarily useful in tests. */ + includeGlobal?: boolean; + /** Runtime probes default to the current process values. */ + nodeVersion?: string; + platform?: NodeJS.Platform; + arch?: string; +} + +/** + * Optional host hooks. The default implementation performs ordinary dynamic + * import and safe, non-shell npm/pnpm root probes. A future WSL worker can + * provide equivalent hooks inside Linux without importing Linux packages into + * the Windows supervisor. + */ +export interface CursorSdkLoaderDependencies { + importModule?: (specifier: string) => Promise; + resolvePackageManagerRoots?: (input: { + platform: NodeJS.Platform; + env: Readonly>; + }) => Promise; +} + +interface CursorSdkPackageJson { + name?: unknown; + version?: unknown; + main?: unknown; + module?: unknown; + exports?: unknown; + optionalDependencies?: unknown; +} + +export interface DiscoveredCursorSdkPackage { + packageRoot: string; + packageJsonPath: string; + manifest: CursorSdkPackageJson; + version: string; + source: CursorSdkPackageSource; +} + +type PackageInspection = + | { kind: "missing" } + | { kind: "invalid"; reason: string } + | { kind: "found"; value: DiscoveredCursorSdkPackage }; + +interface PackageCandidate { + path: string; + source: CursorSdkPackageSource; +} + +export interface CursorSdkDiscoveryFailure { + diagnostic: CursorSdkDiagnostic; +} + +export async function discoverCursorSdkPackage( + options: CursorSdkLoadOptions = {}, + dependencies: CursorSdkLoaderDependencies = {}, +): Promise { + if (options.configuredPath) { + const configured = await discoverConfiguredPackage( + options.configuredPath, + options.projectCwd ?? process.cwd(), + ); + if (configured) return configured; + return { + diagnostic: { + code: "configured_path_invalid", + message: "The configured Cursor SDK path is not a valid @cursor/sdk installation.", + recoverable: true, + details: { configuredPath: options.configuredPath }, + }, + }; + } + + const env = options.env ?? process.env; + const platform = options.platform ?? process.platform; + + // Filesystem-only candidates come first in the resolution order, so probing + // `npm root -g` / `pnpm root -g` — two real subprocesses on every worker load + // — is deferred until none of them holds an installation. + const freeCandidates: PackageCandidate[] = []; + addProjectCandidates(freeCandidates, options.projectCwd ?? process.cwd()); + if (options.includeGlobal !== false) { + addEnvironmentCandidates(freeCandidates, env); + for (const root of options.globalPackageRoots ?? []) { + addModuleRootCandidates(freeCandidates, root, "global-explicit"); + } + addInferredGlobalCandidates(freeCandidates, platform, env, process.execPath); + } + + const seenCandidates = new Set(); + const checkedPaths: string[] = []; + const free = await inspectCandidates(freeCandidates, seenCandidates, checkedPaths); + if (free) return free; + + if (options.includeGlobal !== false) { + const roots = await ( + dependencies.resolvePackageManagerRoots ?? resolveDefaultPackageManagerRoots + )({ platform, env }); + const managerCandidates: PackageCandidate[] = []; + for (const root of roots) addModuleRootCandidates(managerCandidates, root.root, root.source); + const managed = await inspectCandidates(managerCandidates, seenCandidates, checkedPaths); + if (managed) return managed; + } + + return { + diagnostic: { + code: "package_missing", + message: + "The Cursor SDK is not installed. Install @cursor/sdk in the project or globally, or configure its package path.", + recoverable: true, + details: { checkedPaths }, + }, + }; +} + +export async function resolveCursorSdkPackageEntry( + pkg: DiscoveredCursorSdkPackage, +): Promise<{ entryPath: string } | CursorSdkDiscoveryFailure> { + const exportTarget = resolveExportTarget(pkg.manifest.exports); + const relativeEntry = + exportTarget ?? + (typeof pkg.manifest.module === "string" + ? pkg.manifest.module + : typeof pkg.manifest.main === "string" + ? pkg.manifest.main + : "index.js"); + const entryPath = resolve(pkg.packageRoot, relativeEntry); + const relativeToRoot = relative(pkg.packageRoot, entryPath); + if ( + relativeToRoot === ".." || + relativeToRoot.startsWith(`..${sep}`) || + isAbsolute(relativeToRoot) + ) { + return { + diagnostic: { + code: "package_invalid", + message: "The Cursor SDK package entry points outside its package directory.", + recoverable: true, + details: { packageRoot: pkg.packageRoot }, + }, + }; + } + const info = await statOrUndefined(entryPath); + if (!info?.isFile()) { + return { + diagnostic: { + code: "package_invalid", + message: "The Cursor SDK package entry file is missing.", + recoverable: true, + details: { entryPath }, + }, + }; + } + return { entryPath }; +} + +export async function resolveCursorSdkPlatformHelper( + pkg: DiscoveredCursorSdkPackage, + helperName: string, +): Promise<{ name: string; packageRoot: string; version: string } | CursorSdkDiscoveryFailure> { + const helperRoot = await findResolvablePackageRoot(pkg.packageRoot, helperName); + if (!helperRoot) { + return { + diagnostic: { + code: "platform_helper_missing", + message: `The Cursor SDK platform helper ${helperName} is not installed.`, + recoverable: true, + details: { platformHelper: helperName, sdkVersion: pkg.version }, + }, + }; + } + + const helperManifestPath = join(helperRoot, "package.json"); + let manifest: CursorSdkPackageJson; + try { + manifest = JSON.parse(await readFile(helperManifestPath, "utf8")) as CursorSdkPackageJson; + } catch { + return { + diagnostic: { + code: "platform_helper_incompatible", + message: `The Cursor SDK platform helper ${helperName} has invalid metadata.`, + recoverable: true, + details: { platformHelper: helperName }, + }, + }; + } + if (manifest.name !== helperName || typeof manifest.version !== "string") { + return { + diagnostic: { + code: "platform_helper_incompatible", + message: `The Cursor SDK platform helper ${helperName} has invalid metadata.`, + recoverable: true, + details: { platformHelper: helperName }, + }, + }; + } + + const optionalDependencies = isCursorSdkObjectLike(pkg.manifest.optionalDependencies) + ? pkg.manifest.optionalDependencies + : undefined; + const declaredVersion = + typeof optionalDependencies?.[helperName] === "string" + ? optionalDependencies[helperName] + : pkg.version; + if (manifest.version !== declaredVersion) { + return { + diagnostic: { + code: "platform_helper_incompatible", + message: `The Cursor SDK platform helper ${helperName} does not match the SDK version.`, + recoverable: true, + details: { + platformHelper: helperName, + expectedVersion: declaredVersion, + detectedVersion: manifest.version, + }, + }, + }; + } + return { + name: helperName, + packageRoot: await realpath(helperRoot), + version: manifest.version, + }; +} + +async function discoverConfiguredPackage( + configuredPath: string, + baseDirectory: string, +): Promise { + const absolutePath = isAbsolute(configuredPath) + ? configuredPath + : resolve(baseDirectory, configuredPath); + const info = await statOrUndefined(absolutePath); + if (!info) return undefined; + + if (info.isFile()) { + const start = basename(absolutePath) === "package.json" ? dirname(absolutePath) : absolutePath; + const packageRoot = + info.isFile() && start === absolutePath + ? await findContainingCursorSdkPackage(dirname(absolutePath)) + : start; + if (!packageRoot) return undefined; + const inspected = await inspectPackageRoot(packageRoot, "configured"); + return inspected.kind === "found" ? inspected.value : undefined; + } + + if (!info.isDirectory()) return undefined; + const configuredCandidates = [ + absolutePath, + join(absolutePath, "@cursor", "sdk"), + join(absolutePath, "node_modules", "@cursor", "sdk"), + ]; + if (basename(absolutePath) === "@cursor") configuredCandidates.unshift(join(absolutePath, "sdk")); + + for (const candidate of dedupePaths(configuredCandidates)) { + const inspected = await inspectPackageRoot(candidate, "configured"); + if (inspected.kind === "found") return inspected.value; + } + return undefined; +} + +async function findContainingCursorSdkPackage(start: string): Promise { + let current = resolve(start); + while (true) { + const inspected = await inspectPackageRoot(current, "configured"); + if (inspected.kind === "found") return inspected.value.packageRoot; + const parent = dirname(current); + if (parent === current) return undefined; + current = parent; + } +} + +function addProjectCandidates(candidates: PackageCandidate[], projectCwd: string): void { + let current = resolve(projectCwd); + while (true) { + candidates.push({ + path: join(current, "node_modules", "@cursor", "sdk"), + source: "project", + }); + const parent = dirname(current); + if (parent === current) break; + current = parent; + } +} + +function addEnvironmentCandidates( + candidates: PackageCandidate[], + env: Readonly>, +): void { + for (const root of env.NODE_PATH?.split(delimiter) ?? []) { + if (root.trim()) addModuleRootCandidates(candidates, root.trim(), "node-path"); + } +} + +function addInferredGlobalCandidates( + candidates: PackageCandidate[], + platform: NodeJS.Platform, + env: Readonly>, + executablePath: string, +): void { + const executableDirectory = dirname(executablePath); + const inferredRoot = + platform === "win32" + ? join(executableDirectory, "node_modules") + : resolve(executableDirectory, "..", "lib", "node_modules"); + addModuleRootCandidates(candidates, inferredRoot, "global-inferred"); + + const prefix = env.npm_config_prefix ?? env.NPM_CONFIG_PREFIX; + if (prefix) { + addModuleRootCandidates( + candidates, + platform === "win32" ? join(prefix, "node_modules") : join(prefix, "lib", "node_modules"), + "global-inferred", + ); + } +} + +function addModuleRootCandidates( + candidates: PackageCandidate[], + root: string, + source: CursorSdkPackageSource, +): void { + const absoluteRoot = resolve(root); + if (basename(absoluteRoot) === "sdk" && basename(dirname(absoluteRoot)) === "@cursor") { + candidates.push({ path: absoluteRoot, source }); + return; + } + if (basename(absoluteRoot) === "@cursor") { + candidates.push({ path: join(absoluteRoot, "sdk"), source }); + return; + } + candidates.push({ path: join(absoluteRoot, "@cursor", "sdk"), source }); + candidates.push({ path: join(absoluteRoot, "node_modules", "@cursor", "sdk"), source }); +} + +/** + * Inspect candidates in order, skipping any path already checked in an earlier + * batch. Resolves to the winning package, a fatal `package_invalid` failure, or + * `undefined` when this batch holds no `@cursor/sdk`. + */ +async function inspectCandidates( + candidates: readonly PackageCandidate[], + seen: Set, + checkedPaths: string[], +): Promise { + for (const candidate of candidates) { + const key = process.platform === "win32" ? candidate.path.toLowerCase() : candidate.path; + if (seen.has(key)) continue; + seen.add(key); + checkedPaths.push(candidate.path); + const inspected = await inspectPackageRoot(candidate.path, candidate.source); + if (inspected.kind === "missing") continue; + if (inspected.kind === "invalid") { + return { + diagnostic: { + code: "package_invalid", + message: "A Cursor SDK package was found, but its package metadata is invalid.", + recoverable: true, + details: { packageRoot: candidate.path, reason: inspected.reason }, + }, + }; + } + return inspected.value; + } + return undefined; +} + +function dedupePaths(paths: readonly string[]): string[] { + return [...new Set(paths)]; +} + +async function inspectPackageRoot( + candidateRoot: string, + source: CursorSdkPackageSource, +): Promise { + const packageJsonPath = join(candidateRoot, "package.json"); + const info = await statOrUndefined(packageJsonPath); + if (!info) return { kind: "missing" }; + if (!info.isFile()) return { kind: "invalid", reason: "package.json is not a file" }; + if (info.size > PACKAGE_JSON_MAX_BYTES) { + return { kind: "invalid", reason: "package.json exceeds the size limit" }; + } + + let manifest: CursorSdkPackageJson; + try { + manifest = JSON.parse(await readFile(packageJsonPath, "utf8")) as CursorSdkPackageJson; + } catch { + return { kind: "invalid", reason: "package.json is not valid JSON" }; + } + if (manifest.name !== CURSOR_SDK_PACKAGE_NAME) { + return { kind: "invalid", reason: `package name is not ${CURSOR_SDK_PACKAGE_NAME}` }; + } + if (typeof manifest.version !== "string" || !parseCursorSdkVersion(manifest.version)) { + return { kind: "invalid", reason: "package version is not valid semver" }; + } + + const canonicalRoot = await realpath(candidateRoot); + return { + kind: "found", + value: { + packageRoot: canonicalRoot, + packageJsonPath: join(canonicalRoot, "package.json"), + manifest, + version: manifest.version, + source, + }, + }; +} + +function resolveExportTarget(value: unknown): string | undefined { + if (typeof value === "string") return value; + if (Array.isArray(value)) { + for (const item of value) { + const target = resolveExportTarget(item); + if (target) return target; + } + return undefined; + } + if (!isCursorSdkObjectLike(value)) return undefined; + if ("." in value) return resolveExportTarget(value["."]); + for (const condition of ["import", "node", "default", "require"]) { + if (condition in value) { + const target = resolveExportTarget(value[condition]); + if (target) return target; + } + } + return undefined; +} + +async function findResolvablePackageRoot( + packageRoot: string, + packageName: string, +): Promise { + try { + const requireFromSdk = createRequire(join(packageRoot, "package.json")); + const packageJsonPath = requireFromSdk.resolve(`${packageName}/package.json`); + return dirname(packageJsonPath); + } catch { + // Some packages export-hide package.json. Fall through to the same + // ancestor node_modules search Node uses. + } + + const [scope, name] = packageName.split("/"); + if (!scope || !name) return undefined; + let current = packageRoot; + while (true) { + const candidate = join(current, "node_modules", scope, name); + if (await fileExists(join(candidate, "package.json"))) return candidate; + const parent = dirname(current); + if (parent === current) return undefined; + current = parent; + } +} + +async function resolveDefaultPackageManagerRoots(input: { + platform: NodeJS.Platform; + env: Readonly>; +}): Promise { + const probes = await Promise.all( + (["npm", "pnpm"] as const).map(async (manager) => { + const root = await probePackageManagerRoot(manager, input.platform, input.env); + return root ? { root, source: `global-${manager}` as const } : undefined; + }), + ); + return probes.filter((probe): probe is CursorSdkGlobalPackageRoot => probe !== undefined); +} + +async function probePackageManagerRoot( + manager: "npm" | "pnpm", + platform: NodeJS.Platform, + env: Readonly>, +): Promise { + const output = await new Promise((resolveOutput) => { + const command = platform === "win32" ? env.ComSpec || env.COMSPEC || "cmd.exe" : manager; + const args = + platform === "win32" ? ["/d", "/s", "/c", `${manager}.cmd root -g`] : ["root", "-g"]; + execFile( + command, + args, + { + env: env as NodeJS.ProcessEnv, + timeout: GLOBAL_ROOT_PROBE_TIMEOUT_MS, + maxBuffer: GLOBAL_ROOT_PROBE_MAX_BYTES, + windowsHide: true, + }, + (error, stdout) => resolveOutput(error ? undefined : stdout), + ); + }); + if (!output) return undefined; + return output + .split(/\r?\n/) + .map((line) => line.trim()) + .findLast((line) => line.length > 0 && isAbsolute(line)); +} + +async function statOrUndefined(path: string) { + try { + return await stat(path); + } catch { + return undefined; + } +} + +async function fileExists(path: string): Promise { + try { + await access(path, fsConstants.F_OK); + return true; + } catch { + return false; + } +} diff --git a/src/supervisor/agents/cursor/sdkPrompt.test.ts b/src/supervisor/agents/cursor/sdkPrompt.test.ts new file mode 100644 index 000000000..51ce76646 --- /dev/null +++ b/src/supervisor/agents/cursor/sdkPrompt.test.ts @@ -0,0 +1,108 @@ +import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import type { ProjectLocation } from "@/shared/contracts"; +import { buildCursorSdkUserMessage, cursorSdkPromptPath } from "./sdkPrompt"; + +const tempDirs: string[] = []; + +afterEach(async () => { + await Promise.all(tempDirs.splice(0).map((path) => rm(path, { recursive: true, force: true }))); +}); + +describe("buildCursorSdkUserMessage", () => { + const posixLocation: ProjectLocation = { kind: "posix", path: "/repo" }; + + it("keeps a plain prompt in the SDK string form", async () => { + await expect(buildCursorSdkUserMessage("hello", undefined, posixLocation)).resolves.toBe( + "hello", + ); + }); + + it("preserves ordered text, MCP mentions, file paths, and private instructions", async () => { + await expect( + buildCursorSdkUserMessage( + "ignored because structured segments are authoritative", + [ + { kind: "text", content: "Review " }, + { kind: "file", path: "src/main.ts" }, + { kind: "text", content: " with " }, + { kind: "mcp", id: "docs", name: "docs" }, + { kind: "attachment", path: "/tmp/context.pdf", mimeType: "application/pdf" }, + ], + posixLocation, + "Follow the attached skill.", + ), + ).resolves.toBe( + "Review @/repo/src/main.ts with @docs@/tmp/context.pdf\n\nFollow the attached skill.", + ); + }); + + it("preserves a native skill invocation when it is the only prompt segment", async () => { + await expect( + buildCursorSdkUserMessage( + "", + [ + { + kind: "skill", + name: "review-code", + path: "/repo/.cursor/skills/review-code/SKILL.md", + invocation: "/review-code", + provider: "cursor", + scope: "project", + }, + ], + posixLocation, + ), + ).resolves.toBe("/review-code"); + }); + + it("reads image bytes into the only attachment shape the SDK supports", async () => { + const dir = await mkdtemp(join(tmpdir(), "poracode-cursor-sdk-prompt-")); + tempDirs.push(dir); + const image = join(dir, "shot.png"); + await writeFile(image, Buffer.from([0, 1, 2, 255])); + + await expect( + buildCursorSdkUserMessage( + "see image", + [ + { kind: "text", content: "see image" }, + { kind: "attachment", path: image, mimeType: "image/png" }, + ], + posixLocation, + ), + ).resolves.toEqual({ + text: "see image", + images: [{ data: "AAEC/w==", mimeType: "image/png" }], + }); + }); +}); + +describe("cursorSdkPromptPath", () => { + it("resolves native relative paths", () => { + expect(cursorSdkPromptPath({ kind: "posix", path: "/repo" }, "src/a.ts")).toBe( + "/repo/src/a.ts", + ); + expect(cursorSdkPromptPath({ kind: "windows", path: "C:\\repo" }, "src\\a.ts")).toBe( + "C:\\repo\\src\\a.ts", + ); + }); + + it("turns WSL UNC paths into Linux paths and resolves relative paths in the distro", () => { + const location: ProjectLocation = { + kind: "wsl", + distro: "Ubuntu", + linuxPath: "/home/me/repo", + uncPath: "\\\\wsl.localhost\\Ubuntu\\home\\me\\repo", + }; + expect( + cursorSdkPromptPath(location, "\\\\wsl.localhost\\Ubuntu\\home\\me\\shared\\reference.md"), + ).toBe("/home/me/shared/reference.md"); + expect( + cursorSdkPromptPath(location, "\\\\wsl.localhost\\Debian\\home\\me\\shared\\reference.md"), + ).toBe("\\\\wsl.localhost\\Debian\\home\\me\\shared\\reference.md"); + expect(cursorSdkPromptPath(location, "src/main.ts")).toBe("/home/me/repo/src/main.ts"); + }); +}); diff --git a/src/supervisor/agents/cursor/sdkPrompt.ts b/src/supervisor/agents/cursor/sdkPrompt.ts new file mode 100644 index 000000000..ef4bbfab3 --- /dev/null +++ b/src/supervisor/agents/cursor/sdkPrompt.ts @@ -0,0 +1,114 @@ +import { readFile } from "node:fs/promises"; +import { posix, win32 } from "node:path"; +import type { ProjectLocation, PromptSegment } from "@/shared/contracts"; +import { mimeForImagePath } from "@/shared/promptContent"; +import { parseWslUncPath } from "@/shared/wsl"; + +export interface CursorSdkImage { + data: string; + mimeType: string; +} + +export interface CursorSdkUserMessage { + text: string; + images?: CursorSdkImage[]; +} + +/** + * Raster formats the SDK's inline image slot accepts. Deliberately narrower + * than the shared `isImagePath`, which also matches svg/bmp/ico/avif because it + * additionally drives renderer previews; those would be rejected as inline + * image input, so they stay `@path` mentions instead. + */ +const SDK_IMAGE_EXTENSIONS = /\.(?:png|jpe?g|gif|webp)$/i; + +function isImage( + segment: PromptSegment, +): segment is Extract { + if (segment.kind !== "attachment") return false; + return segment.mimeType?.startsWith("image/") === true + ? true + : SDK_IMAGE_EXTENSIONS.test(segment.path); +} + +/** + * Translate a host-visible prompt path into the path the local SDK loop sees. + * Attachments for WSL projects commonly arrive as `\\wsl.localhost\Distro\…` + * paths because the supervisor reads their bytes on Windows; non-image file + * mentions still need the corresponding Linux path in the SDK prompt. + */ +export function cursorSdkPromptPath(location: ProjectLocation, segmentPath: string): string { + if (location.kind === "wsl") { + const unc = parseWslUncPath(segmentPath); + if (unc) { + // A worker inside one distro cannot safely reinterpret another + // distro's UNC path as its own absolute Linux path. + return unc.distro.toLowerCase() === location.distro.toLowerCase() + ? unc.linuxPath + : segmentPath; + } + return posix.isAbsolute(segmentPath) + ? segmentPath + : posix.join(location.linuxPath, segmentPath); + } + if (location.kind === "windows") { + return win32.isAbsolute(segmentPath) ? segmentPath : win32.join(location.path, segmentPath); + } + return posix.isAbsolute(segmentPath) ? segmentPath : posix.join(location.path, segmentPath); +} + +/** + * Build the public Cursor SDK user-message shape. The SDK has a first-class + * image input but no generic file/PDF block, so those remain explicit + * workspace path mentions for the agent to read with its normal tools. + */ +export async function buildCursorSdkUserMessage( + prompt: string, + segments: readonly PromptSegment[] | undefined, + location: ProjectLocation, + inlineInstructions?: string, +): Promise { + if ((!segments || segments.length === 0) && !inlineInstructions) return prompt; + + const text: string[] = []; + const images: CursorSdkImage[] = []; + + if (segments && segments.length > 0) { + for (const segment of segments) { + if (segment.kind === "text") { + text.push(segment.content); + continue; + } + if (segment.kind === "mcp") { + text.push(`@${segment.name}`); + continue; + } + if (segment.kind === "skill") { + text.push(segment.invocation); + continue; + } + if (isImage(segment)) { + const bytes = await readFile(segment.path); + images.push({ + data: bytes.toString("base64"), + mimeType: segment.mimeType ?? mimeForImagePath(segment.path) ?? "image/png", + }); + continue; + } + text.push(`@${cursorSdkPromptPath(location, segment.path)}`); + } + } else { + text.push(prompt); + } + + if (inlineInstructions) { + if (text.length > 0 && !text.at(-1)?.endsWith("\n")) text.push("\n\n"); + text.push(inlineInstructions); + } + + const message: CursorSdkUserMessage = { + text: text.join(""), + ...(images.length > 0 ? { images } : {}), + }; + return images.length === 0 ? message.text : message; +} diff --git a/src/supervisor/agents/cursor/sdkProtocol.ts b/src/supervisor/agents/cursor/sdkProtocol.ts new file mode 100644 index 000000000..3c58de483 --- /dev/null +++ b/src/supervisor/agents/cursor/sdkProtocol.ts @@ -0,0 +1,262 @@ +/** + * Structural public-protocol types for `@cursor/sdk` 1.0.24. + * + * Poracode deliberately does not import the optional SDK package from its + * provider boundary. Keeping the small, stable event envelopes here lets the + * runtime load a user-installed SDK dynamically while this mapper remains + * type-safe and independently testable. + * + * Tool payloads are intentionally loose. Cursor documents the tool-call + * envelope as stable, but explicitly says tool names, arguments, results, and + * raw shell events may change as tools evolve. + */ + +export interface CursorSdkTokenUsage { + inputTokens: number; + outputTokens: number; + cacheReadTokens: number; + cacheWriteTokens: number; + totalTokens: number; + reasoningTokens?: number; +} + +export interface CursorSdkModelSelection { + id: string; + params?: Array<{ id: string; value: string }>; +} + +interface CursorSdkMessageBase { + agent_id: string; + run_id: string; +} + +export interface CursorSdkSystemMessage extends CursorSdkMessageBase { + type: "system"; + subtype?: "init"; + model?: CursorSdkModelSelection; + tools?: string[]; +} + +export interface CursorSdkUserMessage extends CursorSdkMessageBase { + type: "user"; + message: { + role: "user"; + content: Array<{ type: "text"; text: string }>; + }; +} + +export interface CursorSdkAssistantMessage extends CursorSdkMessageBase { + type: "assistant"; + message: { + role: "assistant"; + content: Array< + | { type: "text"; text: string } + | { type: "tool_use"; id: string; name: string; input: unknown } + >; + }; +} + +export interface CursorSdkThinkingMessage extends CursorSdkMessageBase { + type: "thinking"; + text: string; + thinking_duration_ms?: number; +} + +export interface CursorSdkToolCallMessage extends CursorSdkMessageBase { + type: "tool_call"; + call_id: string; + name: string; + status: "running" | "completed" | "error"; + args?: unknown; + result?: unknown; + truncated?: { args?: boolean; result?: boolean }; +} + +export interface CursorSdkStatusMessage extends CursorSdkMessageBase { + type: "status"; + status: "CREATING" | "RUNNING" | "FINISHED" | "ERROR" | "CANCELLED" | "EXPIRED"; + message?: string; +} + +export interface CursorSdkTaskMessage extends CursorSdkMessageBase { + type: "task"; + status?: string; + text?: string; +} + +/** + * The 1.0.24 SDK emits a request id but exposes no public response method and + * no request kind/details. The canonical mapper therefore never turns this + * envelope into an actionable `request.opened` event. + */ +export interface CursorSdkRequestMessage extends CursorSdkMessageBase { + type: "request"; + request_id: string; +} + +export interface CursorSdkUsageMessage extends CursorSdkMessageBase { + type: "usage"; + usage: CursorSdkTokenUsage; +} + +export type CursorSdkMessage = + | CursorSdkSystemMessage + | CursorSdkUserMessage + | CursorSdkAssistantMessage + | CursorSdkThinkingMessage + | CursorSdkToolCallMessage + | CursorSdkStatusMessage + | CursorSdkTaskMessage + | CursorSdkRequestMessage + | CursorSdkUsageMessage; + +export interface CursorSdkRawToolResult { + status?: "success" | "error" | string; + value?: unknown; + error?: unknown; +} + +/** + * Common structural subset of Cursor's public ToolCall union. + * + * Known 1.0.24 `type` values include shell, write, delete, glob, grep, read, + * edit, ls, readLints, mcp, semSearch, generateImage, createPlan, + * recordScreen, updateTodos, and task. Keeping `type` open preserves forward + * compatibility with newly added tools. + */ +export interface CursorSdkRawToolCall { + type: string; + args?: unknown; + result?: CursorSdkRawToolResult | unknown; + truncated?: { args?: boolean; result?: boolean }; +} + +export interface CursorSdkTextDeltaUpdate { + type: "text-delta"; + text: string; +} + +export interface CursorSdkThinkingDeltaUpdate { + type: "thinking-delta"; + text: string; +} + +export interface CursorSdkThinkingCompletedUpdate { + type: "thinking-completed"; + thinkingDurationMs: number; +} + +export interface CursorSdkToolCallStartedUpdate { + type: "tool-call-started"; + callId: string; + toolCall: CursorSdkRawToolCall; + modelCallId: string; +} + +export interface CursorSdkPartialToolCallUpdate { + type: "partial-tool-call"; + callId: string; + toolCall: CursorSdkRawToolCall; + modelCallId: string; +} + +export interface CursorSdkToolCallCompletedUpdate { + type: "tool-call-completed"; + callId: string; + toolCall: CursorSdkRawToolCall; + modelCallId: string; +} + +export interface CursorSdkTokenDeltaUpdate { + type: "token-delta"; + tokens: number; +} + +export interface CursorSdkStepStartedUpdate { + type: "step-started"; + stepId: number; +} + +export interface CursorSdkStepCompletedUpdate { + type: "step-completed"; + stepId: number; + stepDurationMs: number; +} + +export interface CursorSdkTurnEndedUpdate { + type: "turn-ended"; + usage?: Omit; +} + +export interface CursorSdkUserMessageAppendedUpdate { + type: "user-message-appended"; + userMessage: { + type: "user_message"; + session_id: string; + text: string; + images?: Array<{ type: "base64"; data: string }>; + }; +} + +export interface CursorSdkSummaryUpdate { + type: "summary"; + summary: string; +} + +export interface CursorSdkSummaryStartedUpdate { + type: "summary-started"; +} + +export interface CursorSdkSummaryCompletedUpdate { + type: "summary-completed"; +} + +export interface CursorSdkShellOutputDeltaUpdate { + type: "shell-output-delta"; + event: Record; +} + +export type CursorSdkNestedTaskUpdate = + | CursorSdkTextDeltaUpdate + | CursorSdkThinkingDeltaUpdate + | CursorSdkThinkingCompletedUpdate + | CursorSdkToolCallStartedUpdate + | CursorSdkPartialToolCallUpdate + | CursorSdkToolCallCompletedUpdate + | CursorSdkStepStartedUpdate + | CursorSdkStepCompletedUpdate; + +export interface CursorSdkToolCallDeltaUpdate { + type: "tool-call-delta"; + callId: string; + modelCallId: string; + taskUpdate: CursorSdkNestedTaskUpdate; +} + +export type CursorSdkInteractionUpdate = + | CursorSdkTextDeltaUpdate + | CursorSdkThinkingDeltaUpdate + | CursorSdkThinkingCompletedUpdate + | CursorSdkToolCallStartedUpdate + | CursorSdkPartialToolCallUpdate + | CursorSdkToolCallCompletedUpdate + | CursorSdkToolCallDeltaUpdate + | CursorSdkTokenDeltaUpdate + | CursorSdkStepStartedUpdate + | CursorSdkStepCompletedUpdate + | CursorSdkTurnEndedUpdate + | CursorSdkUserMessageAppendedUpdate + | CursorSdkSummaryUpdate + | CursorSdkSummaryStartedUpdate + | CursorSdkSummaryCompletedUpdate + | CursorSdkShellOutputDeltaUpdate; + +export interface CursorSdkRunResult { + id: string; + status: "finished" | "error" | "cancelled"; + result?: string; + error?: { message: string; code?: string }; + model?: CursorSdkModelSelection; + durationMs?: number; + usage?: CursorSdkTokenUsage; +} diff --git a/src/supervisor/agents/cursor/sdkSession.test.ts b/src/supervisor/agents/cursor/sdkSession.test.ts new file mode 100644 index 000000000..e7f5c38d8 --- /dev/null +++ b/src/supervisor/agents/cursor/sdkSession.test.ts @@ -0,0 +1,1320 @@ +import { describe, expect, it, vi } from "vitest"; +import type { + ProjectLocation, + ResolvedMcpServer, + RuntimeEvent, + ThreadConfig, +} from "@/shared/contracts"; +import type { + CreateStructuredSessionInput, + StructuredSessionListener, + StructuredSessionUpdate, +} from "../base"; +import { cursorSdkAgentId, CursorSdkSession } from "./sdkSession"; +import { CursorSdkWorkerRpcError } from "./sdkWorkerClient"; +import type { + CursorSdkWorkerAgentMessage, + CursorSdkWorkerEvent, + CursorSdkWorkerInitializeInput, + CursorSdkWorkerInitializeResult, + CursorSdkWorkerProbeResult, + CursorSdkWorkerStartInput, + CursorSdkWorkerStartResult, +} from "./sdkWorkerProtocol"; + +const projectLocation: ProjectLocation = { kind: "posix", path: "/repo" }; +const baseConfig: ThreadConfig = { + model: "composer[test=one]", + effort: "high", + mode: "agent", + approvalPolicy: "default", + sandboxMode: "workspace-write", +}; +const initialMcp: ResolvedMcpServer[] = [ + { + id: "docs-id", + name: "docs", + timeoutMs: 10_000, + transport: { + type: "stdio", + command: "node", + args: ["docs.js"], + env: { TOKEN: "opaque" }, + }, + }, +]; + +function input( + overrides: Partial = {}, +): CreateStructuredSessionInput { + return { + threadId: "thread-123456789", + projectLocation, + config: baseConfig, + agentSettings: { + structuredRuntime: "sdk", + sdkPackagePath: "/external/node_modules/@cursor/sdk", + }, + env: { PROJECT_VALUE: "yes" }, + mcpServers: initialMcp, + presentationMode: "gui", + ...overrides, + }; +} + +class FakeWorker { + private listeners = new Set<(event: CursorSdkWorkerEvent) => void>(); + private transportErrorListeners = new Set<(error: Error) => void>(); + + readonly initialize = vi.fn< + (input: CursorSdkWorkerInitializeInput) => Promise + >(async (_input) => ({ + agentId: "agent-1", + model: { id: "composer" }, + })); + readonly start = vi.fn<(input: CursorSdkWorkerStartInput) => Promise>( + async (_input) => ({ runId: "run-1" }), + ); + readonly cancel = vi.fn<(runId?: string) => Promise<{ cancelled: boolean }>>(async (_runId) => ({ + cancelled: true, + })); + readonly reload = vi.fn<() => Promise>(async () => {}); + readonly listMessages = vi.fn< + (input?: { limit?: number; offset?: number }) => Promise + >(async (_input) => [ + { + type: "user", + uuid: "user-1", + agent_id: "agent-1", + message: { role: "user", content: [{ type: "text", text: "hello" }] }, + }, + { + type: "assistant", + uuid: "assistant-1", + agent_id: "agent-1", + message: { role: "assistant", content: [{ type: "text", text: "hi" }] }, + }, + ]); + readonly listModels = vi.fn<(apiKey?: string) => Promise>( + async () => ({ + models: [ + { + id: "composer", + displayName: "Composer", + parameters: [ + { + id: "effort", + values: [ + { value: "medium", displayName: "Medium" }, + { value: "high", displayName: "High" }, + ], + }, + ], + }, + ], + sdkVersion: "1.0.24", + source: "configured" as const, + }), + ); + readonly dispose = vi.fn<() => Promise>(async () => {}); + + onEvent(listener: (event: CursorSdkWorkerEvent) => void): () => void { + this.listeners.add(listener); + return () => this.listeners.delete(listener); + } + + onTransportError(listener: (error: Error) => void): () => void { + this.transportErrorListeners.add(listener); + return () => this.transportErrorListeners.delete(listener); + } + + emit(event: CursorSdkWorkerEvent): void { + for (const listener of this.listeners) listener(event); + } + + crash(error: Error): void { + for (const listener of this.transportErrorListeners) listener(error); + } + + snapshotListeners(): { + emit(event: CursorSdkWorkerEvent): void; + crash(error: Error): void; + } { + const eventListeners = [...this.listeners]; + const transportErrorListeners = [...this.transportErrorListeners]; + return { + emit: (event) => { + for (const listener of eventListeners) listener(event); + }, + crash: (error) => { + for (const listener of transportErrorListeners) listener(error); + }, + }; + } +} + +function recordingListener() { + const updates: StructuredSessionUpdate[] = []; + const events: RuntimeEvent[] = []; + const errors: string[] = []; + let closes = 0; + const listener: StructuredSessionListener = { + onClose: () => { + closes += 1; + }, + onError: (message) => errors.push(message), + onUpdate: (update) => updates.push(update), + onRuntimeEvent: (event) => events.push(event), + }; + return { + listener, + updates, + events, + errors, + get closes() { + return closes; + }, + }; +} + +async function createSession( + worker = new FakeWorker(), + inputOverrides: Partial = {}, +) { + const spawnWorker = vi.fn<(input: unknown) => Promise>(async () => worker); + let id = 0; + const session = await CursorSdkSession.create(input(inputOverrides), { + spawnWorker, + newId: () => `id-${++id}`, + }); + return { session, worker, spawnWorker }; +} + +async function createSessionWithWorkers( + workers: readonly FakeWorker[], + inputOverrides: Partial = {}, +) { + let workerIndex = 0; + const spawnWorker = vi.fn<(input: unknown) => Promise>(async () => { + const worker = workers[workerIndex++]; + if (!worker) throw new Error("No fake Cursor SDK worker remains."); + return worker; + }); + let id = 0; + const session = await CursorSdkSession.create(input(inputOverrides), { + spawnWorker, + newId: () => `id-${++id}`, + }); + return { session, spawnWorker }; +} + +function resultEvent( + status: "finished" | "error" | "cancelled" = "finished", + runId = "run-1", +): CursorSdkWorkerEvent { + return { + type: "result", + requestId: "request-1", + runId, + result: { + id: runId, + status, + ...(status === "error" ? { error: { message: "backend failed", code: "backend" } } : {}), + }, + }; +} + +function deferred() { + let resolve!: (value: Value | PromiseLike) => void; + let reject!: (reason?: unknown) => void; + const promise = new Promise((resolvePromise, rejectPromise) => { + resolve = resolvePromise; + reject = rejectPromise; + }); + return { promise, resolve, reject }; +} + +describe("CursorSdkSession", () => { + it("spawns the isolated worker and creates a fresh durable local agent", async () => { + const { session, worker, spawnWorker } = await createSession(); + const recorded = recordingListener(); + session.setListener(recorded.listener); + + await session.activate(); + const id = await session.openThread(baseConfig); + + expect(spawnWorker).toHaveBeenCalledWith({ + projectLocation, + configuredPath: "/external/node_modules/@cursor/sdk", + env: { PROJECT_VALUE: "yes" }, + }); + expect(worker.listModels).toHaveBeenCalledWith(); + expect(worker.initialize).toHaveBeenCalledWith({ + createOptions: { + model: { + id: "composer", + params: [ + { id: "test", value: "one" }, + { id: "effort", value: "high" }, + ], + }, + name: "poracode/thread-1", + local: { + cwd: "/repo", + settingSources: ["all"], + autoReview: true, + sandboxOptions: { enabled: true }, + }, + mcpServers: { + docs: { + type: "stdio", + command: "node", + args: ["docs.js"], + env: { TOKEN: "opaque" }, + }, + }, + mode: "agent", + agentId: cursorSdkAgentId("thread-123456789"), + }, + }); + expect(id).toBe("sdk:agent-1"); + expect(session.launchOptions.resumeThreadId).toBe("sdk:agent-1"); + expect(recorded.updates.at(-1)).toMatchObject({ + status: "idle", + attention: "none", + sessionRef: { providerSessionId: "sdk:agent-1" }, + }); + }); + + it("derives a stable Cursor-shaped local identity from the Poracode thread", () => { + const first = cursorSdkAgentId("thread-123456789"); + expect(first).toMatch( + /^agent-[0-9a-f]{8}-[0-9a-f]{4}-5[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/, + ); + expect(cursorSdkAgentId("thread-123456789")).toBe(first); + expect(cursorSdkAgentId("another-thread")).not.toBe(first); + }); + + it("expires the crash-stale initial run after deterministic create recovery", async () => { + const worker = new FakeWorker(); + worker.initialize.mockResolvedValue({ + agentId: "agent-recovered", + recoveredExisting: true, + }); + const { session } = await createSession(worker); + await session.activate(); + await session.openThread(baseConfig); + + const turn = session.startTurn("recover create", baseConfig); + await vi.waitFor(() => expect(worker.start).toHaveBeenCalledOnce()); + expect(worker.start.mock.calls[0]?.[0].options?.local).toEqual({ force: true }); + worker.emit(resultEvent()); + await turn; + }); + + it("passes a scoped SDK key over worker RPC instead of relying on target argv", async () => { + const { session, worker } = await createSession(new FakeWorker(), { + env: { PROJECT_VALUE: "yes", CURSOR_API_KEY: "scoped-secret" }, + }); + await session.activate(); + await session.openThread(baseConfig); + + expect(worker.listModels).toHaveBeenCalledWith("scoped-secret"); + expect(worker.initialize).toHaveBeenCalledWith( + expect.objectContaining({ apiKey: "scoped-secret" }), + ); + }); + + it("passes the supervisor SDK key explicitly when the GUI session has no env override", async () => { + const previous = process.env.CURSOR_API_KEY; + process.env.CURSOR_API_KEY = "supervisor-secret"; + try { + const { session, worker } = await createSession(new FakeWorker(), { + env: {}, + }); + await session.activate(); + await session.openThread(baseConfig); + + expect(worker.listModels).toHaveBeenCalledWith("supervisor-secret"); + expect(worker.initialize).toHaveBeenCalledWith( + expect.objectContaining({ apiKey: "supervisor-secret" }), + ); + } finally { + if (previous === undefined) delete process.env.CURSOR_API_KEY; + else process.env.CURSOR_API_KEY = previous; + } + }); + + it("strips the runtime prefix on resume and forwards explicit false safety settings", async () => { + const config: ThreadConfig = { + model: "composer", + mode: "plan", + approvalPolicy: "never", + sandboxMode: "danger-full-access", + }; + const { session, worker } = await createSession(); + await session.activate(); + + await session.openThread(config, { + providerSessionId: "sdk:existing-agent", + discoveredAt: "2026-01-01T00:00:00.000Z", + }); + + expect(worker.initialize).toHaveBeenCalledWith({ + resumeAgentId: "existing-agent", + createOptions: expect.objectContaining({ + mode: "plan", + local: { + cwd: "/repo", + settingSources: ["all"], + autoReview: false, + sandboxOptions: { enabled: false }, + }, + }), + }); + expect(worker.initialize.mock.calls[0]?.[0].createOptions.agentId).toBeUndefined(); + }); + + it("reopens a local resume when the cloud model catalog is temporarily unavailable", async () => { + const worker = new FakeWorker(); + worker.listModels.mockRejectedValue(new Error("catalog unavailable")); + const { session } = await createSession(worker); + await session.activate(); + + await expect( + session.openThread(baseConfig, { + providerSessionId: "sdk:existing-agent", + discoveredAt: "2026-01-01T00:00:00.000Z", + }), + ).resolves.toBe("sdk:agent-1"); + expect(worker.initialize).toHaveBeenCalledWith( + expect.objectContaining({ resumeAgentId: "existing-agent" }), + ); + const initializeOptions = worker.initialize.mock.calls[0]?.[0].createOptions; + expect(initializeOptions?.agentId).toBeUndefined(); + expect(initializeOptions?.model).toBeUndefined(); + }); + + it.each(["auth_missing", "auth_invalid"])( + "does not bypass an SDK %s error while resuming", + async (code) => { + const worker = new FakeWorker(); + worker.listModels.mockRejectedValue( + new CursorSdkWorkerRpcError({ + name: "AuthenticationError", + message: "SDK authentication failed", + code, + }), + ); + const { session } = await createSession(worker); + await session.activate(); + + await expect( + session.openThread(baseConfig, { + providerSessionId: "sdk:existing-agent", + discoveredAt: "2026-01-01T00:00:00.000Z", + }), + ).rejects.toMatchObject({ code }); + expect(worker.initialize).not.toHaveBeenCalled(); + }, + ); + + it("keeps catalog authentication mandatory for a fresh local agent", async () => { + const worker = new FakeWorker(); + worker.listModels.mockRejectedValue(new Error("catalog unavailable")); + const { session } = await createSession(worker); + await session.activate(); + + await expect(session.openThread(baseConfig)).rejects.toThrow("catalog unavailable"); + expect(worker.initialize).not.toHaveBeenCalled(); + }); + + it.each([ + { label: "omitted", config: { model: "composer" } satisfies ThreadConfig }, + { + label: "unknown", + config: { + model: "composer", + approvalPolicy: "future-policy", + sandboxMode: "future-sandbox", + } satisfies ThreadConfig, + }, + ])("keeps safe SDK defaults for $label generic safety settings", async ({ config }) => { + const { session, worker } = await createSession(); + await session.activate(); + + await session.openThread(config); + + expect(worker.initialize).toHaveBeenCalledWith({ + createOptions: expect.objectContaining({ + local: { + cwd: "/repo", + settingSources: ["all"], + autoReview: true, + sandboxOptions: { enabled: true }, + }, + }), + }); + }); + + it("rebinds both unrestricted safety settings before sending and fences the old worker", async () => { + const originalWorker = new FakeWorker(); + const replacementWorker = new FakeWorker(); + const { session, spawnWorker } = await createSessionWithWorkers([ + originalWorker, + replacementWorker, + ]); + const recorded = recordingListener(); + session.setListener(recorded.listener); + await session.activate(); + await session.openThread(baseConfig); + const staleListeners = originalWorker.snapshotListeners(); + const unrestrictedConfig: ThreadConfig = { + ...baseConfig, + approvalPolicy: "never", + sandboxMode: "danger-full-access", + }; + + const turn = session.startTurn("run unrestricted", unrestrictedConfig); + await vi.waitFor(() => expect(replacementWorker.start).toHaveBeenCalledOnce()); + + expect(spawnWorker).toHaveBeenCalledTimes(2); + expect(replacementWorker.initialize).toHaveBeenCalledWith({ + resumeAgentId: "agent-1", + createOptions: { + model: { + id: "composer", + params: [ + { id: "test", value: "one" }, + { id: "effort", value: "high" }, + ], + }, + name: "poracode/thread-1", + local: { + cwd: "/repo", + settingSources: ["all"], + autoReview: false, + sandboxOptions: { enabled: false }, + }, + mcpServers: { + docs: { + type: "stdio", + command: "node", + args: ["docs.js"], + env: { TOKEN: "opaque" }, + }, + }, + mode: "agent", + }, + }); + expect(replacementWorker.initialize.mock.calls[0]?.[0].createOptions).not.toHaveProperty( + "agentId", + ); + expect(originalWorker.reload).not.toHaveBeenCalled(); + expect(originalWorker.start).not.toHaveBeenCalled(); + expect(originalWorker.dispose).toHaveBeenCalledOnce(); + expect(replacementWorker.reload).toHaveBeenCalledOnce(); + expect(replacementWorker.start.mock.calls[0]?.[0].options?.local).toEqual({ + force: true, + }); + + staleListeners.emit({ + type: "delta", + requestId: "stale-request", + runId: "run-1", + update: { type: "text-delta", text: "stale output" }, + }); + staleListeners.emit(resultEvent()); + staleListeners.crash(new Error("retired worker crashed")); + await Promise.resolve(); + + expect( + recorded.events.some( + (event) => event.type === "content.delta" && event.delta === "stale output", + ), + ).toBe(false); + expect(recorded.updates.at(-1)).toMatchObject({ + status: "working", + sessionRef: { providerSessionId: "sdk:agent-1" }, + }); + expect(recorded.errors).toEqual([]); + expect(recorded.closes).toBe(0); + + replacementWorker.emit(resultEvent()); + await turn; + expect(recorded.updates.at(-1)).toMatchObject({ + status: "idle", + sessionRef: { providerSessionId: "sdk:agent-1" }, + }); + }); + + it("ignores old-worker events while a replacement is still initializing", async () => { + const originalWorker = new FakeWorker(); + const replacementWorker = new FakeWorker(); + const initialization = deferred(); + replacementWorker.initialize.mockReturnValue(initialization.promise); + replacementWorker.start.mockResolvedValue({ runId: "run-replacement" }); + const { session } = await createSessionWithWorkers([originalWorker, replacementWorker]); + const recorded = recordingListener(); + session.setListener(recorded.listener); + await session.activate(); + await session.openThread(baseConfig); + + const turn = session.startTurn("change safety", { + ...baseConfig, + approvalPolicy: "never", + }); + await vi.waitFor(() => expect(replacementWorker.initialize).toHaveBeenCalledOnce()); + + originalWorker.emit({ + type: "delta", + requestId: "stale-request", + runId: "run-stale", + update: { type: "text-delta", text: "pre-commit stale output" }, + }); + originalWorker.emit(resultEvent("finished", "run-stale")); + await Promise.resolve(); + + expect( + recorded.events.some( + (event) => event.type === "content.delta" && event.delta === "pre-commit stale output", + ), + ).toBe(false); + expect(recorded.events.some((event) => event.type === "turn.completed")).toBe(false); + expect(recorded.updates.at(-1)).toMatchObject({ status: "working" }); + expect(originalWorker.start).not.toHaveBeenCalled(); + expect(replacementWorker.start).not.toHaveBeenCalled(); + + initialization.resolve({ agentId: "agent-1" }); + await vi.waitFor(() => expect(replacementWorker.start).toHaveBeenCalledOnce()); + replacementWorker.emit(resultEvent("finished", "run-replacement")); + await turn; + + expect(recorded.events.at(-1)).toMatchObject({ + type: "turn.completed", + state: "completed", + }); + expect(recorded.updates.at(-1)).toMatchObject({ status: "idle" }); + }); + + it("awaits in-flight replacement cleanup when disposed during initialize", async () => { + const originalWorker = new FakeWorker(); + const replacementWorker = new FakeWorker(); + const initialization = deferred(); + const replacementCleanup = deferred(); + replacementWorker.initialize.mockReturnValue(initialization.promise); + replacementWorker.dispose.mockReturnValue(replacementCleanup.promise); + const { session } = await createSessionWithWorkers([originalWorker, replacementWorker]); + const recorded = recordingListener(); + session.setListener(recorded.listener); + await session.activate(); + await session.openThread(baseConfig); + + const turn = session.startTurn("change safety", { + ...baseConfig, + sandboxMode: "danger-full-access", + }); + await vi.waitFor(() => expect(replacementWorker.initialize).toHaveBeenCalledOnce()); + + let disposeResolved = false; + const disposal = session.dispose().then(() => { + disposeResolved = true; + }); + await vi.waitFor(() => expect(replacementWorker.dispose).toHaveBeenCalledOnce()); + + expect(originalWorker.dispose).toHaveBeenCalledOnce(); + expect(disposeResolved).toBe(false); + expect(recorded.closes).toBe(0); + expect(replacementWorker.reload).not.toHaveBeenCalled(); + expect(replacementWorker.start).not.toHaveBeenCalled(); + + replacementCleanup.resolve(); + await disposal; + await turn; + + expect(disposeResolved).toBe(true); + expect(recorded.closes).toBe(1); + expect(recorded.events.at(-1)).toMatchObject({ + type: "turn.completed", + state: "cancelled", + }); + initialization.resolve({ agentId: "agent-1" }); + await Promise.resolve(); + expect(replacementWorker.dispose).toHaveBeenCalledOnce(); + expect(replacementWorker.start).not.toHaveBeenCalled(); + }); + + it.each([ + { + label: "approval review", + changedConfig: { + ...baseConfig, + approvalPolicy: "never", + } satisfies ThreadConfig, + changedLocal: { + autoReview: false, + sandboxOptions: { enabled: true }, + }, + }, + { + label: "sandboxing", + changedConfig: { + ...baseConfig, + sandboxMode: "danger-full-access", + } satisfies ThreadConfig, + changedLocal: { + autoReview: true, + sandboxOptions: { enabled: false }, + }, + }, + ])( + "rebinds when $label changes and when it switches back", + async ({ changedConfig, changedLocal }) => { + const originalWorker = new FakeWorker(); + const changedWorker = new FakeWorker(); + const restoredWorker = new FakeWorker(); + changedWorker.start.mockResolvedValue({ runId: "run-changed" }); + restoredWorker.start.mockResolvedValue({ runId: "run-restored" }); + const { session, spawnWorker } = await createSessionWithWorkers([ + originalWorker, + changedWorker, + restoredWorker, + ]); + await session.activate(); + await session.openThread(baseConfig); + + const changedTurn = session.startTurn("change safety", changedConfig); + await vi.waitFor(() => expect(changedWorker.start).toHaveBeenCalledOnce()); + expect(changedWorker.initialize.mock.calls[0]?.[0]).toMatchObject({ + resumeAgentId: "agent-1", + createOptions: { + local: { + cwd: "/repo", + settingSources: ["all"], + ...changedLocal, + }, + }, + }); + changedWorker.emit(resultEvent("finished", "run-changed")); + await changedTurn; + + const restoredTurn = session.startTurn("restore safety", baseConfig); + await vi.waitFor(() => expect(restoredWorker.start).toHaveBeenCalledOnce()); + expect(restoredWorker.initialize.mock.calls[0]?.[0]).toMatchObject({ + resumeAgentId: "agent-1", + createOptions: { + local: { + cwd: "/repo", + settingSources: ["all"], + autoReview: true, + sandboxOptions: { enabled: true }, + }, + }, + }); + restoredWorker.emit(resultEvent("finished", "run-restored")); + await restoredTurn; + + expect(spawnWorker).toHaveBeenCalledTimes(3); + expect(originalWorker.dispose).toHaveBeenCalledOnce(); + expect(changedWorker.dispose).toHaveBeenCalledOnce(); + expect(restoredWorker.dispose).not.toHaveBeenCalled(); + }, + ); + + it("does not rebind for model, mode, or MCP-only changes", async () => { + const worker = new FakeWorker(); + worker.start + .mockResolvedValueOnce({ runId: "run-model" }) + .mockResolvedValueOnce({ runId: "run-mode" }) + .mockResolvedValueOnce({ runId: "run-mcp" }); + const { session, spawnWorker } = await createSession(worker); + await session.activate(); + await session.openThread(baseConfig); + + const modelTurn = session.startTurn("change model options", { + ...baseConfig, + model: "composer[test=two]", + effort: "medium", + }); + await vi.waitFor(() => expect(worker.start).toHaveBeenCalledTimes(1)); + worker.emit(resultEvent("finished", "run-model")); + await modelTurn; + + const modeTurn = session.startTurn("change mode", { + ...baseConfig, + mode: "plan", + }); + await vi.waitFor(() => expect(worker.start).toHaveBeenCalledTimes(2)); + worker.emit(resultEvent("finished", "run-mode")); + await modeTurn; + + await session.updateMcpServers([ + { + id: "remote-id", + name: "remote", + timeoutMs: 20_000, + transport: { + type: "http", + url: "https://example.test/mcp", + headers: {}, + }, + }, + ]); + const mcpTurn = session.startTurn("change MCP", baseConfig); + await vi.waitFor(() => expect(worker.start).toHaveBeenCalledTimes(3)); + worker.emit(resultEvent("finished", "run-mcp")); + await mcpTurn; + + expect(spawnWorker).toHaveBeenCalledOnce(); + expect(worker.initialize).toHaveBeenCalledOnce(); + expect(worker.start.mock.calls[0]?.[0].options).toMatchObject({ + model: { + id: "composer", + params: [ + { id: "test", value: "two" }, + { id: "effort", value: "medium" }, + ], + }, + }); + expect(worker.start.mock.calls[1]?.[0].options?.mode).toBe("plan"); + expect(worker.start.mock.calls[2]?.[0].options?.mcpServers).toEqual({ + remote: { + type: "http", + url: "https://example.test/mcp", + }, + }); + }); + + it.each(["spawn", "initialize", "identity"] as const)( + "keeps the old worker usable when replacement %s fails without sending under stale safety", + async (failurePoint) => { + const originalWorker = new FakeWorker(); + const failedWorker = new FakeWorker(); + if (failurePoint === "initialize") { + failedWorker.initialize.mockRejectedValue(new Error("replacement initialize failed")); + } else if (failurePoint === "identity") { + failedWorker.initialize.mockResolvedValue({ agentId: "agent-unexpected" }); + } + const spawnWorker = vi.fn<(input: unknown) => Promise>(); + spawnWorker.mockResolvedValueOnce(originalWorker); + if (failurePoint === "spawn") { + spawnWorker.mockRejectedValueOnce(new Error("replacement spawn failed")); + } else { + spawnWorker.mockResolvedValueOnce(failedWorker); + } + let id = 0; + const session = await CursorSdkSession.create(input(), { + spawnWorker, + newId: () => `id-${++id}`, + }); + const recorded = recordingListener(); + session.setListener(recorded.listener); + await session.activate(); + await session.openThread(baseConfig); + + await expect( + session.startTurn("change safety", { + ...baseConfig, + approvalPolicy: "never", + }), + ).resolves.toBeUndefined(); + + expect(originalWorker.reload).not.toHaveBeenCalled(); + expect(originalWorker.start).not.toHaveBeenCalled(); + expect(originalWorker.dispose).not.toHaveBeenCalled(); + expect(failedWorker.reload).not.toHaveBeenCalled(); + expect(failedWorker.start).not.toHaveBeenCalled(); + expect(failedWorker.dispose).toHaveBeenCalledTimes(failurePoint === "spawn" ? 0 : 1); + expect(recorded.events.at(-1)).toMatchObject({ + type: "turn.completed", + state: "failed", + }); + + const recoveredTurn = session.startTurn("use original safety", baseConfig); + await vi.waitFor(() => expect(originalWorker.start).toHaveBeenCalledOnce()); + expect(spawnWorker).toHaveBeenCalledTimes(2); + originalWorker.emit(resultEvent()); + await recoveredTurn; + expect(recorded.updates.at(-1)).toMatchObject({ + status: "idle", + sessionRef: { providerSessionId: "sdk:agent-1" }, + }); + }, + ); + + it("normalizes a same-distro WSL UNC package path before worker discovery", async () => { + const worker = new FakeWorker(); + const location: ProjectLocation = { + kind: "wsl", + distro: "Ubuntu", + linuxPath: "/home/me/repo", + uncPath: "\\\\wsl.localhost\\Ubuntu\\home\\me\\repo", + }; + const { session, spawnWorker } = await createSession(worker, { + projectLocation: location, + agentSettings: { + structuredRuntime: "sdk", + sdkPackagePath: "\\\\wsl.localhost\\Ubuntu\\home\\me\\sdk", + }, + }); + + await session.activate(); + + expect(spawnWorker).toHaveBeenCalledWith({ + projectLocation: location, + configuredPath: "/home/me/sdk", + env: { PROJECT_VALUE: "yes" }, + }); + }); + + it("maps raw and normalized output once and completes with idle state", async () => { + const { session, worker } = await createSession(); + const recorded = recordingListener(); + session.setListener(recorded.listener); + await session.activate(); + await session.openThread(baseConfig); + + const turn = session.startTurn("hello", baseConfig, undefined, { + userMessageItemId: "optimistic-user", + }); + await vi.waitFor(() => expect(worker.start).toHaveBeenCalledOnce()); + expect(worker.reload).toHaveBeenCalledOnce(); + expect(worker.reload.mock.invocationCallOrder[0]).toBeLessThan( + worker.start.mock.invocationCallOrder[0]!, + ); + worker.emit({ + type: "delta", + requestId: "request-1", + runId: "run-1", + update: { type: "text-delta", text: "answer" }, + }); + worker.emit({ + type: "message", + requestId: "request-1", + runId: "run-1", + message: { + type: "assistant", + agent_id: "agent-1", + run_id: "run-1", + message: { role: "assistant", content: [{ type: "text", text: "answer" }] }, + }, + }); + worker.emit({ + type: "message", + requestId: "request-1", + runId: "run-1", + message: { + type: "status", + agent_id: "agent-1", + run_id: "run-1", + status: "FINISHED", + }, + }); + worker.emit(resultEvent()); + await turn; + + expect(worker.start).toHaveBeenCalledWith({ + message: "hello", + options: { + model: { + id: "composer", + params: [ + { id: "test", value: "one" }, + { id: "effort", value: "high" }, + ], + }, + mode: "agent", + mcpServers: expect.objectContaining({ docs: expect.any(Object) }), + idempotencyKey: "turn-id-1", + }, + }); + expect( + recorded.events + .filter((event) => event.type === "content.delta") + .map((event) => event.delta) + .join(""), + ).toBe("answer"); + expect( + recorded.events.some( + (event) => event.type === "item.started" && event.itemId === "optimistic-user", + ), + ).toBe(false); + expect(recorded.events.at(-1)).toMatchObject({ + type: "turn.completed", + state: "completed", + }); + expect(recorded.updates.at(-1)).toMatchObject({ status: "idle", attention: "none" }); + }); + + it("fails canonically when ambient Cursor configuration cannot reload", async () => { + const worker = new FakeWorker(); + worker.reload.mockRejectedValue(new Error("reload failed")); + const { session } = await createSession(worker); + const recorded = recordingListener(); + session.setListener(recorded.listener); + await session.activate(); + await session.openThread(baseConfig); + + await expect(session.startTurn("use latest config", baseConfig)).resolves.toBeUndefined(); + + expect(worker.start).not.toHaveBeenCalled(); + expect(recorded.events).toContainEqual({ + type: "error", + threadId: "thread-123456789", + message: "reload failed", + }); + expect(recorded.events.at(-1)).toMatchObject({ + type: "turn.completed", + state: "failed", + }); + }); + + it("forces only the first send after resume to recover a persisted stale run", async () => { + const { session, worker } = await createSession(); + await session.activate(); + await session.openThread(baseConfig, { + providerSessionId: "sdk:existing-agent", + discoveredAt: "2026-01-01T00:00:00.000Z", + }); + + const first = session.startTurn("recover", baseConfig); + await vi.waitFor(() => expect(worker.start).toHaveBeenCalledOnce()); + expect(worker.start.mock.calls[0]?.[0].options?.local).toEqual({ force: true }); + worker.emit(resultEvent()); + await first; + + const second = session.startTurn("continue", baseConfig); + await vi.waitFor(() => expect(worker.start).toHaveBeenCalledTimes(2)); + expect(worker.start.mock.calls[1]?.[0].options?.local).toBeUndefined(); + worker.emit(resultEvent()); + await second; + }); + + it("surfaces worker run errors canonically and leaves the thread in error", async () => { + const { session, worker } = await createSession(); + const recorded = recordingListener(); + session.setListener(recorded.listener); + await session.activate(); + await session.openThread(baseConfig); + + const turn = session.startTurn("fail", baseConfig); + await vi.waitFor(() => expect(worker.start).toHaveBeenCalledOnce()); + worker.emit({ + type: "run-error", + requestId: "request-1", + runId: "run-1", + error: { name: "NetworkError", message: "network unavailable", code: "network" }, + }); + await turn; + + expect(recorded.events).toContainEqual({ + type: "error", + threadId: "thread-123456789", + message: "network unavailable", + }); + expect(recorded.events.at(-1)).toMatchObject({ + type: "turn.completed", + state: "failed", + }); + expect(recorded.updates.at(-1)).toMatchObject({ + status: "error", + attention: "error", + errorMessage: "network unavailable", + }); + }); + + it("turns a rejected start command into a failed canonical turn", async () => { + const worker = new FakeWorker(); + worker.start.mockRejectedValue(new Error("send rejected")); + const { session } = await createSession(worker); + const recorded = recordingListener(); + session.setListener(recorded.listener); + await session.activate(); + await session.openThread(baseConfig); + + await expect(session.startTurn("fail immediately", baseConfig)).resolves.toBeUndefined(); + + expect(recorded.events).toContainEqual({ + type: "error", + threadId: "thread-123456789", + message: "send rejected", + }); + expect(recorded.events.at(-1)).toMatchObject({ + type: "turn.completed", + state: "failed", + }); + expect(recorded.updates.at(-1)).toMatchObject({ + status: "error", + errorMessage: "send rejected", + }); + }); + + it("cancels exactly once when interrupted before start returns its run id", async () => { + const worker = new FakeWorker(); + const start = deferred(); + worker.start.mockImplementation(async () => start.promise); + const { session } = await createSession(worker); + await session.activate(); + await session.openThread(baseConfig); + + const turn = session.startTurn("long task", baseConfig); + await vi.waitFor(() => expect(worker.start).toHaveBeenCalledOnce()); + const interrupt = session.interruptTurn(); + start.resolve({ runId: "run-1" }); + await interrupt; + expect(worker.cancel).toHaveBeenCalledTimes(1); + expect(worker.cancel).toHaveBeenCalledWith("run-1"); + worker.emit(resultEvent("cancelled")); + await turn; + }); + + it("cancels exactly once when force-completion precedes a late start acknowledgement", async () => { + const worker = new FakeWorker(); + const start = deferred(); + worker.start.mockImplementation(async () => start.promise); + const { session } = await createSession(worker); + await session.activate(); + await session.openThread(baseConfig); + + const turn = session.startTurn("long task", baseConfig); + await vi.waitFor(() => expect(worker.start).toHaveBeenCalledOnce()); + const interrupt = session.interruptTurn(); + session.forceCompleteTurn(); + start.resolve({ runId: "run-1" }); + + await Promise.all([turn, interrupt]); + expect(worker.cancel).toHaveBeenCalledTimes(1); + expect(worker.cancel).toHaveBeenCalledWith("run-1"); + }); + + it("cancels exactly once when disposal precedes a late start acknowledgement", async () => { + const worker = new FakeWorker(); + const start = deferred(); + worker.start.mockImplementation(async () => start.promise); + const { session } = await createSession(worker); + await session.activate(); + await session.openThread(baseConfig); + + const turn = session.startTurn("long task", baseConfig); + await vi.waitFor(() => expect(worker.start).toHaveBeenCalledOnce()); + const interrupt = session.interruptTurn(); + await session.dispose(); + start.resolve({ runId: "run-1" }); + + await Promise.all([turn, interrupt]); + expect(worker.cancel).toHaveBeenCalledTimes(1); + expect(worker.cancel).toHaveBeenCalledWith("run-1"); + }); + + it("does not repeat an early-event cancellation after a late start acknowledgement", async () => { + const worker = new FakeWorker(); + const start = deferred(); + worker.start.mockImplementation(async () => start.promise); + const { session } = await createSession(worker); + await session.activate(); + await session.openThread(baseConfig); + + const turn = session.startTurn("long task", baseConfig); + await vi.waitFor(() => expect(worker.start).toHaveBeenCalledOnce()); + const interrupt = session.interruptTurn(); + worker.emit({ + type: "delta", + requestId: "request-1", + runId: "run-1", + update: { type: "text-delta", text: "early" }, + }); + session.forceCompleteTurn(); + start.resolve({ runId: "run-1" }); + + await Promise.all([turn, interrupt]); + expect(worker.cancel).toHaveBeenCalledTimes(1); + expect(worker.cancel).toHaveBeenCalledWith("run-1"); + }); + + it("fails and releases an active turn when the worker exits after start acknowledgement", async () => { + const { session, worker } = await createSession(); + const recorded = recordingListener(); + session.setListener(recorded.listener); + await session.activate(); + await session.openThread(baseConfig); + const turn = session.startTurn("long task", baseConfig); + await vi.waitFor(() => expect(worker.start).toHaveBeenCalledOnce()); + + worker.crash(new Error("Cursor SDK worker exited unexpectedly (code 9).")); + await turn; + + expect(recorded.events).toContainEqual({ + type: "error", + threadId: "thread-123456789", + message: "Cursor SDK worker exited unexpectedly (code 9).", + }); + expect(recorded.events.at(-1)).toMatchObject({ + type: "turn.completed", + state: "failed", + }); + expect(recorded.updates.at(-1)).toMatchObject({ + status: "error", + attention: "error", + errorMessage: "Cursor SDK worker exited unexpectedly (code 9).", + }); + expect(recorded.errors).toEqual(["Cursor SDK worker exited unexpectedly (code 9)."]); + expect(recorded.closes).toBe(1); + + await session.dispose(); + expect(recorded.closes).toBe(1); + }); + + it("buffers early runtime events and replays the current state to a late listener", async () => { + const { session, worker } = await createSession(); + await session.activate(); + await session.openThread(baseConfig); + const turn = session.startTurn("hello", baseConfig); + await vi.waitFor(() => expect(worker.start).toHaveBeenCalledOnce()); + worker.emit({ + type: "delta", + requestId: "request-1", + runId: "run-1", + update: { type: "text-delta", text: "early" }, + }); + worker.emit(resultEvent()); + await turn; + + const recorded = recordingListener(); + session.setListener(recorded.listener); + + expect(recorded.events.some((event) => event.type === "turn.started")).toBe(true); + expect(recorded.events.some((event) => event.type === "content.delta")).toBe(true); + expect(recorded.events.at(-1)).toMatchObject({ type: "turn.completed" }); + expect(recorded.updates).toHaveLength(1); + expect(recorded.updates[0]).toMatchObject({ + status: "idle", + sessionRef: { providerSessionId: "sdk:agent-1" }, + }); + }); + + it("uses an updated MCP set on future sends without mutating the active run", async () => { + const { session, worker } = await createSession(); + await session.activate(); + await session.openThread(baseConfig); + const replacement: ResolvedMcpServer[] = [ + { + id: "remote-id", + name: "remote", + timeoutMs: 20_000, + transport: { + type: "http", + url: "https://example.test/mcp", + headers: { Authorization: "Bearer opaque" }, + }, + }, + ]; + await session.updateMcpServers(replacement); + + const turn = session.startTurn("use remote", { ...baseConfig, mode: "plan" }); + await vi.waitFor(() => expect(worker.start).toHaveBeenCalledOnce()); + expect(worker.start.mock.calls[0]![0].options).toMatchObject({ + mode: "plan", + mcpServers: { + remote: { + type: "http", + url: "https://example.test/mcp", + headers: { Authorization: "Bearer opaque" }, + }, + }, + }); + worker.emit(resultEvent()); + await turn; + }); + + it("reads durable messages with prefixed identity and preserves raw info", async () => { + const { session } = await createSession(); + await session.activate(); + await session.openThread(baseConfig); + + await expect(session.readThread()).resolves.toEqual({ + providerSessionId: "sdk:agent-1", + messages: [ + { + messageId: "user-1", + role: "user", + parts: [{ type: "text", text: "hello" }], + info: { + type: "user", + uuid: "user-1", + agent_id: "agent-1", + message: { role: "user", content: [{ type: "text", text: "hello" }] }, + }, + }, + { + messageId: "assistant-1", + role: "assistant", + parts: [{ type: "text", text: "hi" }], + info: { + type: "assistant", + uuid: "assistant-1", + agent_id: "agent-1", + message: { role: "assistant", content: [{ type: "text", text: "hi" }] }, + }, + }, + ], + }); + }); + + it("force-completes an active run, closes its items, and requests cancellation", async () => { + const { session, worker } = await createSession(); + const recorded = recordingListener(); + session.setListener(recorded.listener); + await session.activate(); + await session.openThread(baseConfig); + const turn = session.startTurn("long task", baseConfig); + await vi.waitFor(() => expect(worker.start).toHaveBeenCalledOnce()); + worker.emit({ + type: "delta", + requestId: "request-1", + runId: "run-1", + update: { type: "text-delta", text: "partial" }, + }); + + session.forceCompleteTurn(); + await turn; + + expect(worker.cancel).toHaveBeenCalledWith("run-1"); + expect(recorded.events).toContainEqual(expect.objectContaining({ type: "item.completed" })); + expect(recorded.events.at(-1)).toMatchObject({ + type: "turn.completed", + state: "cancelled", + }); + expect(recorded.updates.at(-1)).toMatchObject({ status: "idle", attention: "none" }); + }); + + it("disposes an active run without leaking late events", async () => { + const { session, worker } = await createSession(); + const recorded = recordingListener(); + session.setListener(recorded.listener); + await session.activate(); + await session.openThread(baseConfig); + const turn = session.startTurn("long task", baseConfig); + await vi.waitFor(() => expect(worker.start).toHaveBeenCalledOnce()); + worker.emit({ + type: "delta", + requestId: "request-1", + runId: "run-1", + update: { type: "text-delta", text: "partial" }, + }); + + await session.dispose(); + await turn; + const eventCount = recorded.events.length; + worker.emit(resultEvent()); + + expect(worker.cancel).toHaveBeenCalledWith("run-1"); + expect(worker.dispose).toHaveBeenCalledOnce(); + expect(recorded.closes).toBe(1); + expect(recorded.events).toHaveLength(eventCount); + expect(recorded.events.at(-1)).toMatchObject({ + type: "turn.completed", + state: "cancelled", + }); + expect((session as unknown as { steerTurn?: unknown }).steerTurn).toBeUndefined(); + }); +}); diff --git a/src/supervisor/agents/cursor/sdkSession.ts b/src/supervisor/agents/cursor/sdkSession.ts new file mode 100644 index 000000000..63483d253 --- /dev/null +++ b/src/supervisor/agents/cursor/sdkSession.ts @@ -0,0 +1,923 @@ +import { createHash, randomUUID } from "node:crypto"; +import type { + ProjectLocation, + PromptSegment, + ResolvedMcpServer, + RuntimeEvent, + SessionRef, + ThreadAttention, + ThreadConfig, + ThreadStatus, +} from "@/shared/contracts"; +import { + createKnownSessionRef, + type AgentLaunchOptions, + type CreateStructuredSessionInput, + type StartTurnOptions, + type StructuredSessionHandle, + type StructuredSessionListener, + type StructuredSessionUpdate, + type ThreadHistory, +} from "../base"; +import { resolveSessionCwd } from "../acp/sessionPaths"; +import { buildCursorSdkMcpServers } from "../userMcp"; +import { + closeCursorSdkOpenItems, + createCursorSdkMapperState, + mapCursorSdkInteractionUpdate, + mapCursorSdkMessage, + mapCursorSdkRunResult, + startCursorSdkTurn, + type CursorSdkMapperState, +} from "./sdkCanonicalMapping"; +import { + buildCursorSdkModelSelection, + type CursorSdkModel, + type CursorSdkModelSelection, +} from "./sdkModels"; +import { buildCursorSdkUserMessage } from "./sdkPrompt"; +import { CursorSdkWorkerRpcError, spawnCursorSdkWorker } from "./sdkWorkerClient"; +import type { + CursorSdkWorkerAgentMessage, + CursorSdkWorkerAgentOptions, + CursorSdkWorkerEvent, + CursorSdkWorkerInitializeInput, + CursorSdkWorkerInitializeResult, + CursorSdkWorkerProbeResult, + CursorSdkWorkerStartInput, + CursorSdkWorkerStartResult, +} from "./sdkWorkerProtocol"; +import { + CURSOR_SDK_SESSION_PREFIX, + cursorSdkConfiguredPath, + cursorSdkSessionId, +} from "./structuredRuntime"; + +interface CursorSdkWorkerHandle { + initialize(input: CursorSdkWorkerInitializeInput): Promise; + start(input: CursorSdkWorkerStartInput): Promise; + cancel(runId?: string): Promise; + reload(): Promise; + listMessages(input?: { limit?: number; offset?: number }): Promise; + listModels(apiKey?: string): Promise; + onEvent(listener: (event: CursorSdkWorkerEvent) => void): () => void; + onTransportError(listener: (error: Error) => void): () => void; + dispose(): Promise; +} + +interface CursorSdkWorkerSpawnInput { + projectLocation: ProjectLocation; + configuredPath?: string; + env?: Record; +} + +interface CursorSdkSafetyPosture { + autoReview: boolean; + sandboxEnabled: boolean; +} + +interface CursorSdkWorkerListeners { + active: boolean; + pendingTransportError?: Error; + unsubscribeEvents(): void; + unsubscribeTransport(): void; +} + +interface CursorSdkReplacementOperation { + cancelled: boolean; + cancellation: Promise; + promise: Promise; + cancel(): void; +} + +export interface CursorSdkSessionDependencies { + spawnWorker(input: CursorSdkWorkerSpawnInput): Promise; + newId(): string; +} + +const DEFAULT_DEPENDENCIES: CursorSdkSessionDependencies = { + spawnWorker: async (input) => spawnCursorSdkWorker(input), + newId: randomUUID, +}; + +interface ActiveTurn { + turnId: string; + worker?: CursorSdkWorkerHandle; + runId?: string; + startRequest?: Promise; + cancelRequested: boolean; + cancelSent: boolean; + settled: boolean; + completion: Promise; + resolveCompletion(): void; +} + +function createActiveTurn(turnId: string): ActiveTurn { + let resolveCompletion!: () => void; + const completion = new Promise((resolve) => { + resolveCompletion = resolve; + }); + return { + turnId, + cancelRequested: false, + cancelSent: false, + settled: false, + completion, + resolveCompletion, + }; +} + +function sdkMode(config: ThreadConfig): "agent" | "plan" { + return config.mode === "plan" ? "plan" : "agent"; +} + +function sdkAutoReview(config: ThreadConfig): boolean { + return config.approvalPolicy !== "never"; +} + +function sdkSandboxEnabled(config: ThreadConfig): boolean { + return config.sandboxMode !== "danger-full-access"; +} + +function sdkSafetyPosture(config: ThreadConfig): CursorSdkSafetyPosture { + return { + autoReview: sdkAutoReview(config), + sandboxEnabled: sdkSandboxEnabled(config), + }; +} + +function sameSdkSafetyPosture( + left: CursorSdkSafetyPosture, + right: CursorSdkSafetyPosture, +): boolean { + return left.autoReview === right.autoReview && left.sandboxEnabled === right.sandboxEnabled; +} + +function createReplacementOperation(): CursorSdkReplacementOperation { + let resolveCancellation!: () => void; + const cancellation = new Promise((resolve) => { + resolveCancellation = resolve; + }); + const operation: CursorSdkReplacementOperation = { + cancelled: false, + cancellation, + promise: Promise.resolve(), + cancel: () => { + if (operation.cancelled) return; + operation.cancelled = true; + resolveCancellation(); + }, + }; + return operation; +} + +/** + * Give every fresh local SDK agent a stable Cursor-shaped identity before the + * provider creates any durable state. If Poracode or the worker exits after + * Agent.create() but before the returned session ref is persisted, retrying + * the same thread can safely resume this identity instead of orphaning a + * second conversation. + */ +export function cursorSdkAgentId(threadId: string): string { + const digest = createHash("sha256") + .update("poracode/cursor-sdk/agent-id\0", "utf8") + .update(threadId, "utf8") + .digest("hex"); + const versioned = `5${digest.slice(13, 16)}`; + const variant = ((Number.parseInt(digest[16]!, 16) & 0x3) | 0x8).toString(16); + return `agent-${digest.slice(0, 8)}-${digest.slice(8, 12)}-${versioned}-${variant}${digest.slice(17, 20)}-${digest.slice(20, 32)}`; +} + +function nativeAgentId(sessionRef: SessionRef | undefined): string | undefined { + const id = sessionRef?.providerSessionId; + if (!id) return undefined; + return id.startsWith(CURSOR_SDK_SESSION_PREFIX) ? id.slice(CURSOR_SDK_SESSION_PREFIX.length) : id; +} + +function errorMessage(error: unknown): string { + if (error instanceof Error && error.message.trim()) return error.message; + return typeof error === "string" && error.trim() + ? error + : "Cursor SDK run failed without an error message."; +} + +function canResumeWithoutModelCatalog(error: unknown): boolean { + if (!(error instanceof CursorSdkWorkerRpcError)) return true; + return error.code !== "auth_missing" && error.code !== "auth_invalid"; +} + +function messageParts(message: unknown): ReadonlyArray { + if (!message || typeof message !== "object") return [message]; + const content = (message as { content?: unknown }).content; + return Array.isArray(content) ? content : [message]; +} + +/** + * Provider-local structured session for a user-installed `@cursor/sdk`. + * + * The SDK itself stays in an isolated Node worker. This class owns only + * provider-neutral lifecycle state and the translation into Poracode's + * canonical runtime events. + */ +export class CursorSdkSession implements StructuredSessionHandle { + launchOptions: AgentLaunchOptions = { suppressResumeConfigOverrides: true }; + + private readonly mapperState: CursorSdkMapperState; + private listener: StructuredSessionListener | undefined; + private worker: CursorSdkWorkerHandle | undefined; + private workerListeners: CursorSdkWorkerListeners | undefined; + private workerSafetyPosture: CursorSdkSafetyPosture | undefined; + private bufferedRuntimeEvents: RuntimeEvent[] = []; + private modelCatalog: readonly CursorSdkModel[] = []; + private mcpServers: readonly ResolvedMcpServer[]; + private currentConfig: ThreadConfig; + private currentStatus: ThreadStatus = "idle"; + private currentAttention: ThreadAttention = "none"; + private sessionId: string | undefined; + private stableSessionRef: SessionRef | undefined; + private activeTurn: ActiveTurn | undefined; + private replacementOperation: CursorSdkReplacementOperation | undefined; + private disposePromise: Promise | undefined; + private forceStaleRunOnNextSend = false; + private pendingTransportError: string | undefined; + private closeReported = false; + private activated = false; + private disposed = false; + + private get apiKey(): string | undefined { + // The shared GUI session factory normally leaves `input.env` absent and + // structured runtimes inherit the supervisor process environment. Keep an + // explicit scoped override authoritative (notably for WSL/SSH and tests), + // but also honor the ordinary CURSOR_API_KEY used to launch Poracode. + // Passing it over worker RPC is required: SDK 1.0.24 can list models from + // its ambient env while its local agent child later rejects the same key + // unless Agent.create()/resume() receives it explicitly. + const value = this.input.env?.CURSOR_API_KEY?.trim() ?? process.env.CURSOR_API_KEY?.trim(); + return value ? value : undefined; + } + + private constructor( + private readonly input: CreateStructuredSessionInput, + private readonly dependencies: CursorSdkSessionDependencies, + ) { + this.currentConfig = input.config; + this.mcpServers = input.mcpServers ?? []; + this.mapperState = createCursorSdkMapperState(input.threadId); + } + + static create( + input: CreateStructuredSessionInput, + dependencies: Partial = {}, + ): Promise { + return Promise.resolve( + new CursorSdkSession(input, { + ...DEFAULT_DEPENDENCIES, + ...dependencies, + }), + ); + } + + setListener(listener: StructuredSessionListener): void { + this.listener = listener; + if (listener.onRuntimeEvent && this.bufferedRuntimeEvents.length > 0) { + const buffered = this.bufferedRuntimeEvents; + this.bufferedRuntimeEvents = []; + for (const event of buffered) listener.onRuntimeEvent(event); + } + listener.onUpdate(this.currentUpdate()); + if (this.pendingTransportError) { + const message = this.pendingTransportError; + this.pendingTransportError = undefined; + try { + listener.onError(message); + } finally { + this.reportClose(); + } + } + } + + async activate(): Promise { + if (this.disposed) { + throw new Error("Cursor SDK session was disposed before activation."); + } + if (this.activated) { + throw new Error("Cursor SDK session is already active."); + } + this.activated = true; + let worker: CursorSdkWorkerHandle | undefined; + let listeners: CursorSdkWorkerListeners | undefined; + try { + worker = await this.dependencies.spawnWorker(this.workerSpawnInput()); + listeners = this.attachWorkerListeners(worker); + if (listeners.pendingTransportError) throw listeners.pendingTransportError; + this.worker = worker; + this.workerListeners = listeners; + listeners.active = true; + } catch (error) { + this.detachWorkerListeners(listeners); + if (worker) await this.bestEffortDispose(worker); + this.activated = false; + throw error; + } + } + + async openThread(config: ThreadConfig, sessionRef?: SessionRef): Promise { + const worker = this.requireWorker(); + this.currentConfig = config; + + const resumeAgentId = nativeAgentId(sessionRef); + // Catalog lookup authenticates the externally-installed SDK and gives us + // the parameter definitions needed for lossless model selection. A + // resumed local conversation must still be readable during a temporary + // catalog outage; its existing model id/params remain self-describing. + let resumeWithoutCatalog = false; + try { + const probe = this.apiKey ? await worker.listModels(this.apiKey) : await worker.listModels(); + this.modelCatalog = probe.models; + } catch (error) { + if (!resumeAgentId || !canResumeWithoutModelCatalog(error)) throw error; + this.modelCatalog = []; + resumeWithoutCatalog = true; + } + const createOptions = this.buildAgentOptions(config); + const initializeOptions = { ...createOptions }; + // Agent.resume() already receives the durable provider identity as its + // first argument. `AgentOptions.agentId` is a create-only recovery key; + // forwarding Poracode's deterministic fresh-thread id on resume could + // conflict with an older SDK agent that has a different native id. + if (resumeAgentId) delete initializeOptions.agentId; + // Passing a model to Agent.resume() makes the SDK perform another catalog + // lookup. Leave it absent after the preliminary lookup failed so the SDK + // can use the durable checkpoint's model and the thread remains readable. + if (resumeWithoutCatalog) delete initializeOptions.model; + const initialized = await worker.initialize({ + ...(this.apiKey ? { apiKey: this.apiKey } : {}), + ...(resumeAgentId ? { resumeAgentId } : {}), + createOptions: initializeOptions, + }); + this.forceStaleRunOnNextSend = + resumeAgentId !== undefined || initialized.recoveredExisting === true; + this.workerSafetyPosture = sdkSafetyPosture(config); + this.sessionId = initialized.agentId; + this.mapperState.agentId = initialized.agentId; + const initializedModel = initialized.model?.id ?? createOptions.model?.id; + if (initializedModel) this.mapperState.model = initializedModel; + const prefixedId = cursorSdkSessionId(initialized.agentId); + this.stableSessionRef = createKnownSessionRef(prefixedId); + this.launchOptions = { + ...this.launchOptions, + resumeThreadId: prefixedId, + }; + this.emitUpdate({ + status: "idle", + attention: "none", + config, + sessionRef: this.stableSessionRef, + }); + return prefixedId; + } + + async startTurn( + prompt: string, + config: ThreadConfig, + segments?: PromptSegment[], + options?: StartTurnOptions, + ): Promise { + this.requireOpenedWorker(); + if (this.activeTurn && !this.activeTurn.settled) { + throw new Error("Cursor SDK cannot start a second turn while one is running."); + } + + this.currentConfig = config; + const turn = createActiveTurn(`turn-${this.dependencies.newId()}`); + this.activeTurn = turn; + this.emitRuntimeEvents( + startCursorSdkTurn(this.mapperState, turn.turnId, options?.userMessageItemId), + ); + this.emitUpdate({ status: "working", attention: "working", config }); + + try { + const message = await buildCursorSdkUserMessage( + prompt, + segments, + this.input.projectLocation, + options?.inlineInstructions, + ); + if (turn.settled) return; + if (turn.cancelRequested || this.disposed) { + this.completeTurnWithoutWorker(turn, "cancelled"); + return; + } + + await this.rebindWorkerForSafetyPosture(config); + if (turn.settled) return; + if (turn.cancelRequested || this.disposed) { + this.completeTurnWithoutWorker(turn, "cancelled"); + return; + } + + // The SDK snapshots ambient Cursor hooks, MCP configuration, and + // subagent definitions on its Agent handle. Refresh immediately before + // every new turn so an already-open Poracode thread observes edits made + // since its previous send. + const worker = this.requireOpenedWorker(); + turn.worker = worker; + await worker.reload(); + if (turn.settled) return; + if (turn.cancelRequested || this.disposed) { + this.completeTurnWithoutWorker(turn, "cancelled"); + return; + } + + const startRequest = worker.start({ + message, + options: this.buildSendOptions(config, turn.turnId), + }); + turn.startRequest = startRequest; + const started = await startRequest; + this.forceStaleRunOnNextSend = false; + turn.runId = started.runId; + this.mapperState.currentRunId = started.runId; + if (turn.settled) { + if (turn.cancelRequested) await this.sendTurnCancel(turn); + return; + } + if (this.activeTurn !== turn || this.disposed) { + await this.sendTurnCancel(turn); + return; + } + if (turn.cancelRequested) await this.sendTurnCancel(turn); + await turn.completion; + } catch (error) { + if (turn.settled || this.disposed) return; + this.failTurn(turn, errorMessage(error)); + } + } + + async interruptTurn(): Promise { + const turn = this.activeTurn; + if (!turn || turn.settled || this.disposed) return; + turn.cancelRequested = true; + if (turn.runId) { + await this.sendTurnCancel(turn); + return; + } + if (turn.startRequest) { + try { + const started = await turn.startRequest; + turn.runId = started.runId; + await this.sendTurnCancel(turn); + } catch { + // startTurn owns command failures and will close the canonical turn. + } + } + } + + forceCompleteTurn(): void { + const turn = this.activeTurn; + if (!turn || turn.settled) { + this.emitRuntimeEvents(closeCursorSdkOpenItems(this.mapperState)); + return; + } + turn.cancelRequested = true; + void this.sendTurnCancel(turn); + this.completeTurnWithoutWorker(turn, "cancelled"); + } + + async updateMcpServers(mcpServers: readonly ResolvedMcpServer[]): Promise { + this.mcpServers = mcpServers; + } + + async readThread(): Promise { + const worker = this.requireOpenedWorker(); + const messages = await worker.listMessages(); + return { + providerSessionId: cursorSdkSessionId(this.sessionId!), + messages: messages.map((entry) => ({ + messageId: entry.uuid, + role: entry.type, + parts: messageParts(entry.message), + info: entry, + })), + }; + } + + dispose(): Promise { + this.disposePromise ??= this.disposeOnce(); + return this.disposePromise; + } + + private async disposeOnce(): Promise { + this.disposed = true; + const replacementOperation = this.replacementOperation; + replacementOperation?.cancel(); + const worker = this.worker; + const turn = this.activeTurn; + if (turn && !turn.settled) { + turn.cancelRequested = true; + if (turn.runId && turn.worker) { + await this.sendTurnCancel(turn); + } + this.completeTurnWithoutWorker(turn, "cancelled"); + } else { + this.emitRuntimeEvents(closeCursorSdkOpenItems(this.mapperState)); + } + this.detachWorkerListeners(this.workerListeners); + this.workerListeners = undefined; + this.worker = undefined; + this.workerSafetyPosture = undefined; + const [workerDisposal] = await Promise.allSettled([ + worker ? worker.dispose() : Promise.resolve(), + replacementOperation ? replacementOperation.promise.catch(() => {}) : Promise.resolve(), + ]); + this.reportClose(); + if (workerDisposal.status === "rejected") throw workerDisposal.reason; + } + + private buildAgentOptions(config: ThreadConfig): CursorSdkWorkerAgentOptions { + const safetyPosture = sdkSafetyPosture(config); + return { + model: this.modelSelection(config), + name: `poracode/${this.input.threadId.slice(0, 8)}`, + local: { + cwd: resolveSessionCwd(this.input.projectLocation), + settingSources: ["all"], + autoReview: safetyPosture.autoReview, + sandboxOptions: { enabled: safetyPosture.sandboxEnabled }, + }, + mcpServers: buildCursorSdkMcpServers(this.mcpServers), + mode: sdkMode(config), + agentId: cursorSdkAgentId(this.input.threadId), + }; + } + + private buildSendOptions( + config: ThreadConfig, + idempotencyKey: string, + ): NonNullable { + return { + model: this.modelSelection(config), + mode: sdkMode(config), + mcpServers: buildCursorSdkMcpServers(this.mcpServers), + idempotencyKey, + ...(this.forceStaleRunOnNextSend ? { local: { force: true } } : {}), + }; + } + + private modelSelection(config: ThreadConfig): CursorSdkModelSelection { + return buildCursorSdkModelSelection(config, this.modelCatalog); + } + + private workerSpawnInput(): CursorSdkWorkerSpawnInput { + const configuredPath = cursorSdkConfiguredPath( + this.input.agentSettings, + this.input.projectLocation, + ); + return { + projectLocation: this.input.projectLocation, + ...(configuredPath ? { configuredPath } : {}), + ...(this.input.env ? { env: this.input.env } : {}), + }; + } + + private async rebindWorkerForSafetyPosture(config: ThreadConfig): Promise { + const nextSafetyPosture = sdkSafetyPosture(config); + if ( + !this.workerSafetyPosture || + sameSdkSafetyPosture(this.workerSafetyPosture, nextSafetyPosture) + ) { + return; + } + + if (this.replacementOperation) { + await this.replacementOperation.promise; + return; + } + + const operation = createReplacementOperation(); + this.replacementOperation = operation; + operation.promise = Promise.resolve() + .then(() => this.performSafetyPostureRebind(config, nextSafetyPosture, operation)) + .finally(() => { + if (this.replacementOperation === operation) { + this.replacementOperation = undefined; + } + }); + await operation.promise; + } + + private async performSafetyPostureRebind( + config: ThreadConfig, + nextSafetyPosture: CursorSdkSafetyPosture, + operation: CursorSdkReplacementOperation, + ): Promise { + const previousWorker = this.requireOpenedWorker(); + const previousSessionId = this.sessionId!; + const createOptions = { ...this.buildAgentOptions(config) }; + delete createOptions.agentId; + let replacementWorker: CursorSdkWorkerHandle | undefined; + let replacementListeners: CursorSdkWorkerListeners | undefined; + let committed = false; + + try { + replacementWorker = await this.dependencies.spawnWorker(this.workerSpawnInput()); + if (operation.cancelled || this.disposed) { + throw new Error("Cursor SDK session was disposed while applying safety settings."); + } + if (this.requireOpenedWorker() !== previousWorker) { + throw new Error("Cursor SDK worker changed while applying safety settings."); + } + const initialized = await Promise.race([ + replacementWorker.initialize({ + ...(this.apiKey ? { apiKey: this.apiKey } : {}), + resumeAgentId: previousSessionId, + createOptions, + }), + operation.cancellation.then(() => { + throw new Error("Cursor SDK session was disposed while applying safety settings."); + }), + ]); + if (initialized.agentId !== previousSessionId) { + throw new Error("Cursor SDK replacement returned a different agent identity."); + } + if (operation.cancelled || this.disposed) { + throw new Error("Cursor SDK session was disposed while applying safety settings."); + } + if (this.requireOpenedWorker() !== previousWorker || this.sessionId !== previousSessionId) { + throw new Error("Cursor SDK worker changed while applying safety settings."); + } + replacementListeners = this.attachWorkerListeners(replacementWorker); + if (replacementListeners.pendingTransportError) { + throw replacementListeners.pendingTransportError; + } + + const previousListeners = this.workerListeners; + if (previousListeners) previousListeners.active = false; + this.worker = replacementWorker; + this.workerListeners = replacementListeners; + replacementListeners.active = true; + this.workerSafetyPosture = nextSafetyPosture; + this.forceStaleRunOnNextSend = true; + this.mapperState.agentId = initialized.agentId; + const initializedModel = initialized.model?.id ?? createOptions.model?.id; + if (initializedModel) this.mapperState.model = initializedModel; + committed = true; + + this.detachWorkerListeners(previousListeners); + await this.bestEffortDispose(previousWorker); + } catch (error) { + if (!committed) { + this.detachWorkerListeners(replacementListeners); + if (replacementWorker) await this.bestEffortDispose(replacementWorker); + } + throw error; + } + } + + private attachWorkerListeners(worker: CursorSdkWorkerHandle): CursorSdkWorkerListeners { + const listeners: CursorSdkWorkerListeners = { + active: false, + unsubscribeEvents: () => {}, + unsubscribeTransport: () => {}, + }; + try { + listeners.unsubscribeEvents = worker.onEvent((event) => { + if (!listeners.active || this.worker !== worker) return; + this.handleWorkerEvent(worker, event); + }); + listeners.unsubscribeTransport = worker.onTransportError((error) => { + if (!listeners.active) { + listeners.pendingTransportError ??= error; + return; + } + if (this.worker !== worker) return; + this.handleWorkerTransportError(error); + }); + return listeners; + } catch (error) { + this.detachWorkerListeners(listeners); + throw error; + } + } + + private detachWorkerListeners(listeners: CursorSdkWorkerListeners | undefined): void { + if (!listeners) return; + listeners.active = false; + listeners.unsubscribeEvents(); + listeners.unsubscribeTransport(); + } + + private async bestEffortDispose(worker: CursorSdkWorkerHandle): Promise { + try { + await worker.dispose(); + } catch { + // A replacement is already authoritative, or the candidate never became authoritative. + } + } + + private handleWorkerEvent(worker: CursorSdkWorkerHandle, event: CursorSdkWorkerEvent): void { + const turn = this.activeTurn; + if (!turn || turn.settled || turn.worker !== worker || this.disposed) return; + if (this.mapperState.terminalRunIds.has(event.runId)) { + // A normalized terminal status can precede the worker's authoritative + // result envelope. Keep that result so the startTurn promise and shared + // runtime state settle, but discard duplicate/stale stream content. + if (turn.runId !== event.runId || event.type === "delta" || event.type === "message") { + return; + } + } + if (turn.runId && event.runId !== turn.runId) return; + turn.runId = event.runId; + this.mapperState.currentRunId = event.runId; + + if (turn.cancelRequested && !turn.cancelSent) void this.sendTurnCancel(turn); + + if (event.type === "delta") { + this.emitRuntimeEvents(mapCursorSdkInteractionUpdate(event.update, this.mapperState)); + return; + } + if (event.type === "message") { + this.emitRuntimeEvents(mapCursorSdkMessage(event.message, this.mapperState)); + return; + } + if (event.type === "result") { + this.emitRuntimeEvents(mapCursorSdkRunResult(event.result, this.mapperState)); + this.settleTurn(turn, event.result.status, event.result.error?.message); + return; + } + this.emitRuntimeEvents( + mapCursorSdkRunResult( + { + id: event.runId, + status: "error", + error: { + message: event.error.message, + ...(event.error.code ? { code: event.error.code } : {}), + }, + }, + this.mapperState, + ), + ); + this.settleTurn(turn, "error", event.error.message); + } + + private handleWorkerTransportError(error: Error): void { + if (this.disposed || this.pendingTransportError || this.closeReported) return; + const message = errorMessage(error); + const turn = this.activeTurn; + if (turn && !turn.settled) { + this.failTurn(turn, message); + } else { + this.emitUpdate({ + status: "error", + attention: "error", + errorMessage: message, + }); + } + this.detachWorkerListeners(this.workerListeners); + this.workerListeners = undefined; + if (this.listener) { + try { + this.listener.onError(message); + } finally { + this.reportClose(); + } + } else { + this.pendingTransportError = message; + } + } + + private settleTurn( + turn: ActiveTurn, + status: "finished" | "error" | "cancelled", + failureMessage?: string, + ): void { + if (turn.settled) return; + turn.settled = true; + if (this.activeTurn === turn) this.activeTurn = undefined; + if (status === "error") { + this.emitUpdate({ + status: "error", + attention: "error", + ...(failureMessage ? { errorMessage: failureMessage } : {}), + }); + } else { + this.emitUpdate({ status: "idle", attention: "none" }); + } + turn.resolveCompletion(); + } + + private failTurn(turn: ActiveTurn, message: string): void { + const runId = turn.runId ?? turn.turnId; + this.emitRuntimeEvents( + mapCursorSdkRunResult( + { + id: runId, + status: "error", + error: { message }, + }, + this.mapperState, + ), + ); + this.settleTurn(turn, "error", message); + } + + private completeTurnWithoutWorker(turn: ActiveTurn, state: "cancelled" | "failed"): void { + if (turn.settled) return; + const runId = turn.runId ?? turn.turnId; + this.emitRuntimeEvents( + mapCursorSdkRunResult( + { + id: runId, + status: state === "cancelled" ? "cancelled" : "error", + ...(state === "failed" + ? { error: { message: "Cursor SDK turn was force-completed." } } + : {}), + }, + this.mapperState, + ), + ); + this.settleTurn(turn, state === "cancelled" ? "cancelled" : "error"); + } + + private async sendTurnCancel(turn: ActiveTurn): Promise { + const worker = turn.worker; + if (!worker || turn.cancelSent || !turn.runId) return; + turn.cancelSent = true; + await this.bestEffortCancel(worker, turn.runId); + } + + private async bestEffortCancel(worker: CursorSdkWorkerHandle, runId: string): Promise { + try { + await worker.cancel(runId); + } catch { + // Result/error delivery is authoritative; cancellation is best-effort. + } + } + + private currentSessionRef(): SessionRef | undefined { + if (!this.sessionId) return undefined; + this.stableSessionRef ??= createKnownSessionRef(cursorSdkSessionId(this.sessionId)); + return this.stableSessionRef; + } + + private reportClose(): void { + if (this.closeReported || !this.listener) return; + this.closeReported = true; + this.listener.onClose(); + } + + private currentUpdate(): StructuredSessionUpdate { + return { + status: this.currentStatus, + attention: this.currentAttention, + config: this.currentConfig, + ...(this.currentSessionRef() ? { sessionRef: this.currentSessionRef()! } : {}), + }; + } + + private emitUpdate(update: StructuredSessionUpdate): void { + this.currentStatus = update.status; + this.currentAttention = update.attention; + const sessionRef = this.currentSessionRef(); + this.listener?.onUpdate({ + ...update, + ...(update.config ? {} : { config: this.currentConfig }), + ...(update.sessionRef ? {} : sessionRef ? { sessionRef } : {}), + }); + } + + private emitRuntimeEvents(events: readonly RuntimeEvent[]): void { + if (events.length === 0) return; + if (!this.listener?.onRuntimeEvent) { + this.bufferedRuntimeEvents.push(...events); + return; + } + for (const event of events) this.listener.onRuntimeEvent(event); + } + + private requireWorker(): CursorSdkWorkerHandle { + if (this.pendingTransportError) { + throw new Error(this.pendingTransportError); + } + if (this.disposed || this.closeReported || !this.worker) { + throw new Error("Cursor SDK session is not active."); + } + return this.worker; + } + + private requireOpenedWorker(): CursorSdkWorkerHandle { + const worker = this.requireWorker(); + if (!this.sessionId) { + throw new Error("Cursor SDK session has not opened an agent."); + } + return worker; + } +} + +export function createCursorSdkSession( + input: CreateStructuredSessionInput, + dependencies?: Partial, +): Promise { + return CursorSdkSession.create(input, dependencies); +} diff --git a/src/supervisor/agents/cursor/sdkWorker.integration.test.ts b/src/supervisor/agents/cursor/sdkWorker.integration.test.ts new file mode 100644 index 000000000..5f7cde6b0 --- /dev/null +++ b/src/supervisor/agents/cursor/sdkWorker.integration.test.ts @@ -0,0 +1,773 @@ +import { execFileSync, spawn, type ChildProcess } from "node:child_process"; +import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { afterEach, describe, expect, it } from "vitest"; +import { CursorSdkWorkerClient, CursorSdkWorkerRpcError } from "./sdkWorkerClient"; +import type { CursorSdkWorkerEvent } from "./sdkWorkerProtocol"; + +const tempDirectories: string[] = []; +const children: ChildProcess[] = []; + +afterEach(() => { + for (const child of children.splice(0)) child.kill(); + for (const directory of tempDirectories.splice(0)) { + rmSync(directory, { recursive: true, force: true }); + } +}); + +describe("Cursor SDK worker integration", () => { + it("probes, creates, streams deltas/messages/results, and exposes stored messages", async () => { + const harness = await createHarness(); + const probe = await harness.client.probe("probe-key"); + expect(probe).toEqual({ + models: [ + { + id: "composer-test", + displayName: "Composer Test", + parameters: [{ id: "effort", values: [{ value: "high" }] }], + }, + ], + sdkVersion: "unknown", + source: "explicit-entry", + }); + + await expect( + harness.client.initialize({ + apiKey: "worker-secret", + createOptions: { + model: { id: "composer-test" }, + name: "Poracode test", + local: { + cwd: harness.directory, + settingSources: ["project", "user", "plugins"], + sandboxOptions: { enabled: true }, + autoReview: true, + enableAgentRetries: false, + }, + mcpServers: { + test: { command: "test-mcp", args: ["--stdio"] }, + }, + mode: "plan", + }, + }), + ).resolves.toEqual({ + agentId: "agent-created", + model: { id: "composer-test" }, + }); + + const events: CursorSdkWorkerEvent[] = []; + const unsubscribe = harness.client.onEvent((event) => events.push(event)); + const started = await harness.client.start({ + message: { + text: "hello", + images: [{ data: "aGVsbG8=", mimeType: "image/png" }], + }, + options: { + mode: "agent", + model: { id: "composer-test", params: [{ id: "effort", value: "high" }] }, + local: { force: true }, + }, + }); + expect(started).toEqual({ runId: "run-1" }); + await waitFor(() => events.some((event) => event.type === "result")); + unsubscribe(); + + expect(events.map((event) => event.type)).toEqual( + expect.arrayContaining(["delta", "message", "result"]), + ); + expect(events).toContainEqual( + expect.objectContaining({ + type: "delta", + runId: "run-1", + update: { type: "text-delta", text: "hello" }, + }), + ); + expect(events).toContainEqual( + expect.objectContaining({ + type: "delta", + runId: "run-1", + update: { + type: "user-message-appended", + userMessage: { + type: "user_message", + session_id: "fixture-session", + text: "hello", + }, + }, + }), + ); + const echoedUserMessage = events.find( + (event) => event.type === "delta" && event.update.type === "user-message-appended", + ); + if ( + echoedUserMessage?.type !== "delta" || + echoedUserMessage.update.type !== "user-message-appended" + ) { + throw new Error("Expected the sanitized user-message-appended fixture event."); + } + expect(echoedUserMessage.update.userMessage).not.toHaveProperty("images"); + expect(events).toContainEqual( + expect.objectContaining({ + type: "message", + runId: "run-1", + message: expect.objectContaining({ type: "assistant" }), + }), + ); + expect(events).toContainEqual( + expect.objectContaining({ + type: "result", + runId: "run-1", + result: expect.objectContaining({ id: "run-1", status: "finished", result: "done" }), + }), + ); + + await harness.client.reload(); + await expect(harness.client.listMessages({ limit: 10, offset: 0 })).resolves.toEqual([ + expect.objectContaining({ + type: "assistant", + message: { operations: ["reload"] }, + }), + ]); + await harness.client.dispose(); + }); + + it("keeps reading commands while a stream is active so cancel can finish it", async () => { + const harness = await createHarness(); + await harness.client.initialize({ + createOptions: { + model: { id: "composer-test" }, + local: { cwd: harness.directory }, + }, + }); + const events: CursorSdkWorkerEvent[] = []; + harness.client.onEvent((event) => events.push(event)); + const { runId } = await harness.client.start({ message: "hold" }); + await waitFor(() => events.some((event) => event.type === "message")); + + await expect(harness.client.cancel(runId)).resolves.toEqual({ cancelled: true }); + await waitFor(() => events.some((event) => event.type === "result")); + expect(events).toContainEqual( + expect.objectContaining({ + type: "result", + runId, + result: expect.objectContaining({ status: "cancelled" }), + }), + ); + await expect(harness.client.cancel()).resolves.toEqual({ cancelled: false }); + await harness.client.dispose(); + }); + + it("reports stream errors as run events and remains usable for a follow-up", async () => { + const harness = await createHarness(); + await harness.client.initialize({ + createOptions: { + model: { id: "composer-test" }, + local: { cwd: harness.directory }, + }, + }); + const events: CursorSdkWorkerEvent[] = []; + harness.client.onEvent((event) => events.push(event)); + await harness.client.start({ message: "stream-error" }); + await waitFor(() => events.some((event) => event.type === "run-error")); + expect(events).toContainEqual( + expect.objectContaining({ + type: "run-error", + error: expect.objectContaining({ message: "synthetic stream failure" }), + }), + ); + + await harness.client.start({ message: "follow-up" }); + await waitFor(() => events.filter((event) => event.type === "result").length === 1); + await harness.client.dispose(); + }); + + it("resumes an existing agent and rejects conflicting lifecycle requests", async () => { + const harness = await createHarness(); + await expect( + harness.client.initialize({ + resumeAgentId: "agent-existing", + createOptions: { + model: { id: "composer-test" }, + local: { cwd: harness.directory }, + }, + }), + ).resolves.toEqual({ + agentId: "agent-existing", + model: { id: "composer-test" }, + }); + await expect( + harness.client.initialize({ + createOptions: { + model: { id: "composer-test" }, + local: { cwd: harness.directory }, + }, + }), + ).rejects.toMatchObject({ code: "already_initialized" }); + + const { runId } = await harness.client.start({ message: "hold" }); + await expect(harness.client.start({ message: "second" })).rejects.toMatchObject({ + code: "agent_busy", + }); + await expect(harness.client.cancel("different-run")).rejects.toMatchObject({ + code: "run_not_active", + }); + await harness.client.cancel(runId); + await harness.client.dispose(); + }); + + it("redacts API keys from SDK errors", async () => { + const harness = await createHarness(); + const error = await harness.client + .initialize({ + apiKey: "do-not-leak-this-key", + createOptions: { + model: { id: "composer-test" }, + name: "throw", + local: { cwd: harness.directory }, + }, + }) + .catch((reason: unknown) => reason); + expect(error).toBeInstanceOf(CursorSdkWorkerRpcError); + expect((error as Error).message).toBe("rejected [REDACTED]"); + expect((error as Error).message).not.toContain("do-not-leak-this-key"); + await harness.client.dispose(); + }); + + it("anchors argv at the verified external SDK entry for platform-helper discovery", async () => { + const harness = await createHarness(); + + await expect(harness.client.probe("argv-anchor")).resolves.toMatchObject({ + models: [{ id: harness.entryPath }], + }); + + await harness.client.dispose(); + }); + + it("awaits the SDK async disposer before acknowledging graceful worker disposal", async () => { + const harness = await createHarness(); + const markerPath = join(harness.directory, "async-disposed.txt"); + await harness.client.initialize({ + createOptions: { + model: { id: "composer-test" }, + name: `dispose-marker:${markerPath}`, + local: { cwd: harness.directory }, + }, + }); + + await harness.client.dispose(); + + expect(readFileSync(markerPath, "utf8")).toBe("async-disposed"); + }); + + it("redacts MCP transport credentials echoed by create and send failures", async () => { + const createHarnessResult = await createHarness(); + const createSecrets = { + env: "mcp-stdio-env-secret-unique", + header: "mcp-http-header-secret-unique", + client: "mcp-oauth-client-secret-unique", + }; + const createError = await createHarnessResult.client + .initialize({ + createOptions: { + model: { id: "composer-test" }, + name: "throw-mcp-secrets", + local: { cwd: createHarnessResult.directory }, + mcpServers: secretMcpServers(createSecrets), + }, + }) + .catch((reason: unknown) => reason); + expect(createError).toBeInstanceOf(CursorSdkWorkerRpcError); + expect((createError as Error).message).toContain("[REDACTED]"); + expect((createError as CursorSdkWorkerRpcError).code).toBe("[REDACTED]"); + for (const secret of Object.values(createSecrets)) { + expect((createError as Error).message).not.toContain(secret); + } + await createHarnessResult.client.dispose(); + + const sendHarnessResult = await createHarness(); + await sendHarnessResult.client.initialize({ + createOptions: { + model: { id: "composer-test" }, + local: { cwd: sendHarnessResult.directory }, + }, + }); + const sendSecrets = { + env: "send-stdio-env-secret-unique", + header: "send-http-header-secret-unique", + client: "send-oauth-client-secret-unique", + }; + const sendError = await sendHarnessResult.client + .start({ + message: "throw-mcp-secrets", + options: { mcpServers: secretMcpServers(sendSecrets) }, + }) + .catch((reason: unknown) => reason); + expect(sendError).toBeInstanceOf(CursorSdkWorkerRpcError); + expect((sendError as Error).message).toContain("[REDACTED]"); + expect((sendError as CursorSdkWorkerRpcError).code).toBe("[REDACTED]"); + for (const secret of Object.values(sendSecrets)) { + expect((sendError as Error).message).not.toContain(secret); + } + await sendHarnessResult.client.dispose(); + }); + + it("redacts known credentials from normal run-result errors and codes", async () => { + const harness = await createHarness(); + const apiKey = "result-api-key-secret-unique"; + const createSecrets = { + env: "result-create-env-secret-unique", + header: "result-create-header-secret-unique", + client: "result-create-client-secret-unique", + }; + const sendSecrets = { + env: "result-send-env-secret-unique", + header: "result-send-header-secret-unique", + client: "result-send-client-secret-unique", + }; + await harness.client.initialize({ + apiKey, + createOptions: { + model: { id: "composer-test" }, + local: { cwd: harness.directory }, + mcpServers: secretMcpServers(createSecrets), + }, + }); + const events: CursorSdkWorkerEvent[] = []; + harness.client.onEvent((event) => events.push(event)); + + await harness.client.start({ + message: "result-error-secrets", + options: { mcpServers: secretMcpServers(sendSecrets) }, + }); + await waitFor(() => events.some((event) => event.type === "result")); + + const event = events.find((candidate) => candidate.type === "result"); + expect(event).toMatchObject({ + type: "result", + result: { + status: "error", + result: "ordinary result remains unchanged", + error: { + message: expect.stringContaining("[REDACTED]"), + code: expect.stringContaining("[REDACTED]"), + }, + }, + }); + const serialized = JSON.stringify(event); + for (const secret of [apiKey, ...Object.values(createSecrets), ...Object.values(sendSecrets)]) { + expect(serialized).not.toContain(secret); + } + await harness.client.dispose(); + }); + + it("redacts known credentials from streamed provider payloads", async () => { + const harness = await createHarness(); + const apiKey = "stream-api-key-secret-unique"; + const createSecrets = { + env: "stream-create-env-secret-unique", + header: "stream-create-header-secret-unique", + client: "stream-create-client-secret-unique", + }; + const sendSecrets = { + env: "stream-send-env-secret-unique", + header: "stream-send-header-secret-unique", + client: "stream-send-client-secret-unique", + }; + await harness.client.initialize({ + apiKey, + createOptions: { + model: { id: "composer-test" }, + local: { cwd: harness.directory }, + mcpServers: secretMcpServers(createSecrets), + }, + }); + const events: CursorSdkWorkerEvent[] = []; + harness.client.onEvent((event) => events.push(event)); + + await harness.client.start({ + message: "stream-secrets", + options: { mcpServers: secretMcpServers(sendSecrets) }, + }); + await waitFor(() => events.some((event) => event.type === "result")); + + const serialized = JSON.stringify(events); + expect(serialized).toContain("[REDACTED]"); + for (const secret of [apiKey, ...Object.values(createSecrets), ...Object.values(sendSecrets)]) { + expect(serialized).not.toContain(secret); + } + await harness.client.dispose(); + }); + + it("normalizes authentication errors returned through normal run results", async () => { + const harness = await createHarness(); + await harness.client.initialize({ + createOptions: { + model: { id: "composer-test" }, + local: { cwd: harness.directory }, + }, + }); + const events: CursorSdkWorkerEvent[] = []; + harness.client.onEvent((event) => events.push(event)); + + await harness.client.start({ message: "result-auth-error" }); + await waitFor(() => events.some((event) => event.type === "result")); + + expect(events).toContainEqual( + expect.objectContaining({ + type: "result", + result: expect.objectContaining({ + status: "error", + error: { + message: "Cursor rejected the configured SDK API key.", + code: "auth_invalid", + }, + }), + }), + ); + await harness.client.dispose(); + }); + + it("resumes a deterministic local agent only when create reports an explicit duplicate", async () => { + const duplicateHarness = await createHarness(); + await expect( + duplicateHarness.client.initialize({ + createOptions: { + agentId: "agent-deterministic-duplicate", + model: { id: "composer-test" }, + local: { cwd: duplicateHarness.directory }, + }, + }), + ).resolves.toMatchObject({ + agentId: "agent-deterministic-duplicate", + recoveredExisting: true, + }); + await duplicateHarness.client.dispose(); + + const authHarness = await createHarness(); + const authError = await authHarness.client + .initialize({ + createOptions: { + agentId: "agent-deterministic-auth-failure", + model: { id: "composer-test" }, + local: { cwd: authHarness.directory }, + }, + }) + .catch((reason: unknown) => reason); + expect(authError).toMatchObject({ + code: "auth_invalid", + message: "Cursor rejected the configured SDK API key.", + }); + await authHarness.client.dispose(); + }); + + it("normalizes Cursor authentication failures to the stable auth diagnostic", async () => { + const harness = await createHarness(); + const error = await harness.client.probe("invalid-key").catch((reason: unknown) => reason); + + expect(error).toBeInstanceOf(CursorSdkWorkerRpcError); + expect(error).toMatchObject({ + code: "auth_invalid", + message: "Cursor rejected the configured SDK API key.", + }); + await harness.client.dispose(); + }); +}); + +async function createHarness(): Promise<{ + directory: string; + entryPath: string; + client: CursorSdkWorkerClient; +}> { + const directory = mkdtempSync(join(tmpdir(), "poracode-cursor-sdk-worker-")); + tempDirectories.push(directory); + const sdkRoot = join(directory, "fake-sdk"); + mkdirSync(sdkRoot, { recursive: true }); + const entryPath = join(sdkRoot, "index.mjs"); + writeFileSync(entryPath, FAKE_SDK_SOURCE, "utf8"); + + const configuredWorkerPath = process.env.PORACODE_CURSOR_SDK_WORKER_TEST_PATH; + const workerPath = configuredWorkerPath ?? join(directory, "cursor-sdk-worker.mjs"); + if (!configuredWorkerPath) { + const workerSource = resolve(dirname(fileURLToPath(import.meta.url)), "sdkWorker.ts"); + execFileSync( + process.platform === "win32" ? "pnpm.cmd" : "pnpm", + [ + "exec", + "esbuild", + workerSource, + "--bundle", + "--platform=node", + "--format=esm", + "--target=node24", + `--outfile=${workerPath}`, + ], + { stdio: "pipe" }, + ); + } + const child = spawn(process.execPath, [workerPath], { + cwd: resolve(dirname(fileURLToPath(import.meta.url)), "../../../.."), + env: { ...process.env, CURSOR_API_KEY: "inherited-test-key" }, + stdio: ["pipe", "pipe", "pipe"], + }); + children.push(child); + const client = new CursorSdkWorkerClient( + child, + { entryPath, packageRoot: sdkRoot }, + directory, + 5_000, + ); + await client.waitUntilReady(5_000); + return { directory, entryPath, client }; +} + +function secretMcpServers(secrets: { + env: string; + header: string; + client: string; +}): NonNullable[0]["createOptions"]["mcpServers"]> { + return { + stdio: { + command: "test-mcp", + env: { MCP_PRIVATE_ENV: secrets.env }, + }, + http: { + type: "http", + url: "https://mcp.example.test", + headers: { Authorization: `Bearer ${secrets.header}` }, + auth: { + CLIENT_ID: "public-client-id", + CLIENT_SECRET: secrets.client, + }, + }, + }; +} + +async function waitFor(predicate: () => boolean, timeoutMs = 3_000): Promise { + const deadline = Date.now() + timeoutMs; + while (!predicate()) { + if (Date.now() >= deadline) throw new Error("Timed out waiting for worker event."); + await new Promise((resolvePromise) => setTimeout(resolvePromise, 10)); + } +} + +const FAKE_SDK_SOURCE = String.raw` +import { writeFile } from "node:fs/promises"; + +const operations = []; +let runCounter = 0; + +class FakeRun { + constructor(agentId, message, errorSecrets = []) { + this.agentId = agentId; + this.message = typeof message === "string" ? message : message.text; + this.errorSecrets = errorSecrets; + this.id = "run-" + (++runCounter); + this.cancelled = false; + this.release = undefined; + } + + async *stream() { + yield { + type: "system", + subtype: "init", + agent_id: this.agentId, + run_id: this.id, + model: { id: "composer-test" }, + }; + if (this.message === "stream-error") throw new Error("synthetic stream failure"); + yield { + type: "assistant", + agent_id: this.agentId, + run_id: this.id, + message: { + role: "assistant", + content: [{ + type: "text", + text: this.message === "stream-secrets" + ? "provider output " + this.errorSecrets.join(" / ") + : "response", + }], + }, + }; + if (this.message === "hold") { + await new Promise((resolve) => { + this.release = resolve; + }); + } + yield { + type: "status", + agent_id: this.agentId, + run_id: this.id, + status: this.cancelled ? "CANCELLED" : "FINISHED", + }; + } + + async wait() { + if (this.message === "result-error-secrets") { + return { + id: this.id, + status: "error", + result: "ordinary result remains unchanged", + error: { + message: "provider failure " + this.errorSecrets.join(" / "), + code: "MCP_" + this.errorSecrets.join("_"), + }, + }; + } + if (this.message === "result-auth-error") { + return { + id: this.id, + status: "error", + error: { message: "Invalid User API Key", code: "BAD_USER_API_KEY" }, + }; + } + return { + id: this.id, + status: this.cancelled ? "cancelled" : "finished", + result: this.cancelled ? "partial" : "done", + model: { id: "composer-test" }, + usage: { + inputTokens: 1, + outputTokens: 2, + cacheReadTokens: 0, + cacheWriteTokens: 0, + }, + }; + } + + async cancel() { + this.cancelled = true; + this.release?.(); + } +} + +class FakeAgent { + constructor(agentId, options) { + this.agentId = agentId; + this.model = options.model; + this.name = options.name; + this.apiKey = options.apiKey; + this.mcpServers = options.mcpServers; + } + + async send(message, options = {}) { + if (message === "throw-mcp-secrets") { + throwMcpSecrets(options.mcpServers); + } + options.onDelta?.({ update: { type: "text-delta", text: "hello" } }); + if (typeof message === "object" && message?.images?.length) { + options.onDelta?.({ + update: { + type: "user-message-appended", + userMessage: { + type: "user_message", + session_id: "fixture-session", + text: message.text, + images: message.images.map((image) => ({ + type: "base64", + data: image.data, + })), + }, + }, + }); + } + return new FakeRun(this.agentId, message, [ + this.apiKey, + ...collectMcpSecrets(this.mcpServers), + ...collectMcpSecrets(options.mcpServers), + ].filter(Boolean)); + } + + async reload() { + operations.push("reload"); + } + + close() {} + + async [Symbol.asyncDispose]() { + if (!this.name?.startsWith("dispose-marker:")) return; + await new Promise((resolve) => setTimeout(resolve, 50)); + await writeFile(this.name.slice("dispose-marker:".length), "async-disposed", "utf8"); + } +} + +export class Agent { + static async create(options) { + if (options.name === "throw") throw new Error("rejected " + options.apiKey); + if (options.name === "throw-mcp-secrets") throwMcpSecrets(options.mcpServers); + if (options.agentId === "agent-deterministic-duplicate") { + throw new Error("Agent " + options.agentId + " already exists"); + } + if (options.agentId === "agent-deterministic-auth-failure") { + const error = new Error("create authentication failed"); + error.name = "AuthenticationError"; + error.code = "BAD_USER_API_KEY"; + throw error; + } + return new FakeAgent(options.agentId ?? "agent-created", options); + } + + static async resume(agentId, options = {}) { + if (agentId === "agent-deterministic-auth-failure") { + throw new Error("authentication failures must not fall back to resume"); + } + if (options.agentId !== undefined) { + throw new Error("create-only agentId leaked into Agent.resume"); + } + return new FakeAgent(agentId, options); + } + + static messages = { + async list(agentId) { + return [{ + type: "assistant", + uuid: "stored-message", + agent_id: agentId, + message: { operations: [...operations] }, + }]; + }, + }; +} + +export class Cursor { + static models = { + async list(options = {}) { + if (options.apiKey === "argv-anchor") { + return [{ id: process.argv[1], displayName: "argv anchor" }]; + } + if (options.apiKey === "invalid-key") { + const error = new Error("Invalid User API Key"); + error.name = "AuthenticationError"; + throw error; + } + return [{ + id: "composer-test", + displayName: "Composer Test", + parameters: [{ id: "effort", values: [{ value: "high" }] }], + }]; + }, + }; +} + +function throwMcpSecrets(servers) { + const error = new Error([ + servers.stdio.env.MCP_PRIVATE_ENV, + servers.http.headers.Authorization, + servers.http.auth.CLIENT_SECRET, + ].join(" / ")); + error.code = servers.http.auth.CLIENT_SECRET; + throw error; +} + +function collectMcpSecrets(servers = {}) { + const values = []; + for (const server of Object.values(servers)) { + if (server.env) values.push(...Object.values(server.env)); + if (server.headers) values.push(...Object.values(server.headers)); + if (server.auth?.CLIENT_SECRET) values.push(server.auth.CLIENT_SECRET); + } + return values; +} +`; diff --git a/src/supervisor/agents/cursor/sdkWorker.ts b/src/supervisor/agents/cursor/sdkWorker.ts new file mode 100644 index 000000000..3348c7240 --- /dev/null +++ b/src/supervisor/agents/cursor/sdkWorker.ts @@ -0,0 +1,398 @@ +#!/usr/bin/env node + +/** + * Isolated runtime for a user-installed `@cursor/sdk`. + * + * This entry is built as a self-contained ESM helper and can run either next + * to the native supervisor or after being staged into WSL. The SDK itself is + * never bundled: discovery and dynamic import happen in this process, where + * platform-specific optional packages and the target login-shell environment + * are valid. + */ +import { createInterface } from "node:readline"; +import type { + CursorSdkInteractionUpdate, + CursorSdkMessage, + CursorSdkRunResult, +} from "./sdkProtocol"; +import { + CURSOR_SDK_WORKER_PROTOCOL_VERSION, + type CursorSdkWorkerAgentMessage, + type CursorSdkWorkerEvent, + type CursorSdkWorkerInitializeParams, + type CursorSdkWorkerInitializeResult, + type CursorSdkWorkerModelsListParams, + type CursorSdkWorkerProbeResult, + type CursorSdkWorkerRequest, + type CursorSdkWorkerSendOptions, + type CursorSdkWorkerStartInput, + type CursorSdkWorkerStartResult, + type CursorSdkWorkerWireMessage, +} from "./sdkWorkerProtocol"; +import { + asWorkerRecord, + CursorSdkWorkerDiagnostics, + WorkerDiagnosticError, +} from "./sdkWorkerDiagnostics"; +import { + CursorSdkWorkerRuntime, + disposeCursorSdkAgent, + openCursorSdkAgent, + type RuntimeAgent, + type RuntimeRun, +} from "./sdkWorkerRuntime"; + +// stdout is a protocol channel, while provider-native console output can carry +// credentials or arbitrary non-JSON text. SDK failures are returned through +// sanitized RPC errors, so suppress console output before importing the +// external package. +for (const method of ["log", "info", "debug", "warn", "error"] as const) { + console[method] = () => undefined; +} + +interface ActiveRun { + requestId: string; + run: RuntimeRun; +} + +const sdkRuntime = new CursorSdkWorkerRuntime(); +const diagnostics = new CursorSdkWorkerDiagnostics(); +let agent: RuntimeAgent | undefined; +let agentCwd: string | undefined; +let activeRun: ActiveRun | undefined; +let startInFlight = false; +let disposed = false; +const workerMethods = new Set([ + "initialize", + "start", + "cancel", + "reload", + "messages.list", + "models.list", + "dispose", +]); + +const readline = createInterface({ + input: process.stdin, + crlfDelay: Number.POSITIVE_INFINITY, +}); + +emit({ + type: "ready", + protocolVersion: CURSOR_SDK_WORKER_PROTOCOL_VERSION, +}); + +readline.on("line", (line) => { + if (!line.trim()) return; + void handleLine(line); +}); + +readline.on("close", () => { + void disposeRuntime(); +}); + +async function handleLine(line: string): Promise { + let request: CursorSdkWorkerRequest; + try { + request = parseRequest(line); + } catch { + // A malformed line has no trustworthy request id, so it cannot receive a + // correlated response. Keep the worker alive for subsequent valid input. + return; + } + + try { + const result = await dispatch(request); + respondOk(request.id, result); + } catch (error) { + respondError(request.id, error); + } +} + +async function dispatch(request: CursorSdkWorkerRequest): Promise { + if (disposed && request.method !== "dispose") { + throw new WorkerDiagnosticError("worker_disposed", "The Cursor SDK worker is disposed."); + } + + switch (request.method) { + case "initialize": + return initialize(request.params); + case "start": + return startRun(request.id, request.params); + case "cancel": + return cancelRun(request.params.runId); + case "reload": + await requireAgent().reload(); + return {}; + case "messages.list": + return listMessages(request.params); + case "models.list": + return listModels(request.params); + case "dispose": + await disposeRuntime(); + return {}; + } +} + +async function initialize( + params: CursorSdkWorkerInitializeParams, +): Promise { + if (agent) { + throw new WorkerDiagnosticError( + "already_initialized", + "The Cursor SDK worker already owns an agent.", + ); + } + diagnostics.rememberSecret(params.apiKey); + diagnostics.rememberMcpSecrets(params.createOptions.mcpServers); + const cwd = firstCwd(params.createOptions.local.cwd); + const loaded = await sdkRuntime.load(params.sdk, cwd, params.apiKey); + agentCwd = cwd; + const options = { + ...params.createOptions, + ...(params.apiKey ? { apiKey: params.apiKey } : {}), + }; + const opened = await openCursorSdkAgent(loaded, options, params.resumeAgentId); + agent = opened.agent; + if (!agent || typeof agent.agentId !== "string" || !agent.agentId) { + agent = undefined; + throw new WorkerDiagnosticError( + "module_api_incompatible", + "The installed Cursor SDK returned an invalid agent handle.", + ); + } + return { + agentId: agent.agentId, + ...(agent.model ? { model: agent.model } : {}), + ...(opened.recoveredExisting ? { recoveredExisting: true } : {}), + }; +} + +async function startRun( + requestId: string, + input: CursorSdkWorkerStartInput, +): Promise { + if (activeRun || startInFlight) { + throw new WorkerDiagnosticError( + "agent_busy", + "The Cursor SDK agent already has an active run.", + ); + } + const currentAgent = requireAgent(); + diagnostics.rememberMcpSecrets(input.options?.mcpServers); + startInFlight = true; + const bufferedDeltas: unknown[] = []; + let runId: string | undefined; + try { + const options: CursorSdkWorkerSendOptions & { + onDelta: (args: { update: unknown }) => void; + } = { + ...(input.options ?? {}), + onDelta: ({ update }) => { + const transportUpdate = sanitizeInteractionUpdate(update); + if (runId) { + emitRunEvent({ + type: "delta", + requestId, + runId, + update: transportUpdate, + }); + } else { + bufferedDeltas.push(transportUpdate); + } + }, + }; + const run = await currentAgent.send(input.message, options); + if (!run || typeof run.id !== "string" || !run.id) { + throw new WorkerDiagnosticError( + "module_api_incompatible", + "The installed Cursor SDK returned an invalid run handle.", + ); + } + runId = run.id; + activeRun = { requestId, run }; + for (const update of bufferedDeltas) { + emitRunEvent({ + type: "delta", + requestId, + runId, + update: update as CursorSdkInteractionUpdate, + }); + } + void pumpRun(activeRun); + return { runId }; + } finally { + startInFlight = false; + } +} + +/** + * The SDK redundantly echoes prompt images in `user-message-appended`. The + * host only consumes the echoed text, and forwarding base64 attachments can + * needlessly exceed the bounded JSON-lines transport after SDK acceptance. + */ +function sanitizeInteractionUpdate(update: unknown): CursorSdkInteractionUpdate { + const candidate = update as CursorSdkInteractionUpdate; + if (candidate?.type !== "user-message-appended") return candidate; + return { + type: "user-message-appended", + userMessage: { + type: "user_message", + session_id: candidate.userMessage.session_id, + text: candidate.userMessage.text, + }, + }; +} + +async function pumpRun(active: ActiveRun): Promise { + const { requestId, run } = active; + try { + for await (const message of run.stream()) { + emitRunEvent({ + type: "message", + requestId, + runId: run.id, + message: message as CursorSdkMessage, + }); + } + const result = await run.wait(); + emitRunEvent({ + type: "result", + requestId, + runId: run.id, + result: diagnostics.sanitizeRunResult(result as CursorSdkRunResult), + }); + } catch (error) { + emitRunEvent({ + type: "run-error", + requestId, + runId: run.id, + error: diagnostics.serializeError(error), + }); + } finally { + if (activeRun?.run === run) activeRun = undefined; + } +} + +async function cancelRun(requestedRunId?: string): Promise<{ cancelled: boolean }> { + const active = activeRun; + if (!active) return { cancelled: false }; + if (requestedRunId && requestedRunId !== active.run.id) { + throw new WorkerDiagnosticError( + "run_not_active", + `Run ${requestedRunId} is not the active Cursor SDK run.`, + ); + } + await active.run.cancel(); + return { cancelled: true }; +} + +async function listMessages(input: { + limit?: number; + offset?: number; +}): Promise { + const currentAgent = requireAgent(); + const cwd = currentAgentCwd(); + return sdkRuntime.module!.Agent.messages.list(currentAgent.agentId, { + runtime: "local", + cwd, + ...(input.limit !== undefined ? { limit: input.limit } : {}), + ...(input.offset !== undefined ? { offset: input.offset } : {}), + }); +} + +async function listModels( + input: CursorSdkWorkerModelsListParams, +): Promise { + diagnostics.rememberSecret(input.apiKey); + const loaded = + sdkRuntime.module ?? (await sdkRuntime.load(input.sdk, input.projectCwd, input.apiKey)); + const models = await loaded.Cursor.models.list( + input.apiKey ? { apiKey: input.apiKey } : undefined, + ); + return { + models, + ...sdkRuntime.metadata, + }; +} + +async function disposeRuntime(): Promise { + if (disposed) return; + disposed = true; + const run = activeRun?.run; + activeRun = undefined; + if (run) { + try { + await run.cancel(); + } catch { + // Best effort: close the agent even if the already-terminal run rejects. + } + } + const currentAgent = agent; + agent = undefined; + agentCwd = undefined; + if (currentAgent) { + await disposeCursorSdkAgent(currentAgent); + } +} + +function requireAgent(): RuntimeAgent { + if (!agent) { + throw new WorkerDiagnosticError( + "not_initialized", + "Initialize the Cursor SDK worker before using an agent operation.", + ); + } + return agent; +} + +function currentAgentCwd(): string { + return agentCwd ?? process.cwd(); +} + +function firstCwd(cwd: string | string[]): string { + return Array.isArray(cwd) ? (cwd[0] ?? process.cwd()) : cwd; +} + +function parseRequest(line: string): CursorSdkWorkerRequest { + const parsed: unknown = JSON.parse(line); + const record = asWorkerRecord(parsed); + if ( + record?.type !== "request" || + typeof record.id !== "string" || + typeof record.method !== "string" || + !workerMethods.has(record.method) || + !asWorkerRecord(record.params) + ) { + throw new WorkerDiagnosticError("protocol_error", "Invalid Cursor SDK worker request."); + } + return parsed as CursorSdkWorkerRequest; +} + +function respondOk(id: string, result: unknown): void { + emit({ type: "response", id, ok: true, result: diagnostics.sanitizePayload(result) }); +} + +function respondError(id: string, error: unknown): void { + emit({ type: "response", id, ok: false, error: diagnostics.serializeError(error) }); +} + +function emitRunEvent(event: CursorSdkWorkerEvent): void { + emit({ type: "event", event: diagnostics.sanitizePayload(event) }); +} + +function emit(message: CursorSdkWorkerWireMessage): void { + process.stdout.write(`${safeStringify(message)}\n`); +} + +function safeStringify(value: unknown): string { + const seen = new WeakSet(); + return JSON.stringify(value, (_key, candidate: unknown) => { + if (typeof candidate === "bigint") return candidate.toString(); + if (candidate && typeof candidate === "object") { + if (seen.has(candidate)) return "[Circular]"; + seen.add(candidate); + } + return candidate; + }); +} diff --git a/src/supervisor/agents/cursor/sdkWorkerClient.test.ts b/src/supervisor/agents/cursor/sdkWorkerClient.test.ts new file mode 100644 index 000000000..4dc9348d6 --- /dev/null +++ b/src/supervisor/agents/cursor/sdkWorkerClient.test.ts @@ -0,0 +1,419 @@ +import { spawn, type ChildProcess, type SpawnOptions } from "node:child_process"; +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { + spawnCursorSdkWorker, + terminateCursorSdkWorkerTree, + type CursorSdkWorkerClientDependencies, + type CursorSdkWorkerSpawnProcess, +} from "./sdkWorkerClient"; + +const tempDirectories: string[] = []; +const children: ChildProcess[] = []; + +afterEach(() => { + for (const child of children.splice(0)) child.kill(); + for (const directory of tempDirectories.splice(0)) { + rmSync(directory, { recursive: true, force: true }); + } +}); + +describe("spawnCursorSdkWorker", () => { + it("boots a native helper, ignores shell banners, and dispatches events safely", async () => { + const fixture = makeProtocolFixture(); + const client = await spawnCursorSdkWorker({ + projectLocation: { kind: "posix", path: fixture.directory }, + workerPath: fixture.path, + configuredPath: "/opt/cursor-sdk", + }); + const received: string[] = []; + client.onEvent(() => { + throw new Error("listener failure"); + }); + client.onEvent((event) => received.push(event.type)); + + await expect(client.probe()).resolves.toEqual({ + models: [{ id: "fixture", displayName: "Fixture" }], + sdkVersion: "1.0.24", + source: "configured", + }); + await expect( + client.initialize({ + createOptions: { + model: { id: "fixture" }, + local: { cwd: fixture.directory }, + }, + }), + ).resolves.toEqual({ agentId: "agent-fixture", model: { id: "fixture" } }); + await expect(client.start({ message: "hello" })).resolves.toEqual({ runId: "run-fixture" }); + await waitFor(() => received.includes("result")); + expect(received).toContain("delta"); + expect(received).toContain("message"); + await client.dispose(); + }); + + it("stages into WSL, uses the resolved in-distro Node, and keeps API keys off argv", async () => { + const fixture = makeProtocolFixture(); + const calls: Array<{ command: string; args: readonly string[]; options: SpawnOptions }> = []; + const originalApiKey = process.env.CURSOR_API_KEY; + delete process.env.CURSOR_API_KEY; + const spawnProcess: CursorSdkWorkerSpawnProcess = (command, args, options) => { + calls.push({ command, args, options }); + const child = spawn(process.execPath, [fixture.path], { + cwd: fixture.directory, + stdio: ["pipe", "pipe", "pipe"], + }); + children.push(child); + return child; + }; + const resolveNode = vi.fn>( + async () => ({ + nodePath: "/home/user/.nvm/node", + nodeVersion: "22.14.0", + source: "user-installed", + }), + ); + + try { + const client = await spawnCursorSdkWorker( + { + projectLocation: { + kind: "wsl", + distro: "Ubuntu", + linuxPath: "/work/repo", + uncPath: "\\\\wsl.localhost\\Ubuntu\\work\\repo", + }, + workerPath: fixture.path, + configuredPath: "/home/user/sdk", + env: { + CURSOR_API_KEY: "must-not-appear-in-command", + PORACODE_SAFE_TEST_VALUE: "visible", + }, + }, + { + spawnProcess, + resolveNode, + deploy: (_distro, baseName, files) => { + expect(baseName).toMatch(/^poracode-cursor-sdk-/); + expect(files).toEqual([ + { + src: fixture.path, + relDest: "cursor-sdk/cursor-sdk-worker.mjs", + }, + ]); + return { linuxBaseDir: "/tmp/poracode-test" }; + }, + }, + ); + + expect(calls).toHaveLength(1); + const [call] = calls; + expect(call!.args).toEqual(expect.arrayContaining(["-d", "Ubuntu", "--cd", "/work/repo"])); + const serializedArgv = JSON.stringify(call!.args); + expect(serializedArgv).toContain("/home/user/.nvm/node"); + expect(serializedArgv).toContain("/tmp/poracode-test/cursor-sdk/cursor-sdk-worker.mjs"); + expect(serializedArgv).toContain("PORACODE_SAFE_TEST_VALUE"); + expect(serializedArgv).not.toContain("must-not-appear-in-command"); + expect(call!.options.env?.CURSOR_API_KEY).toBeUndefined(); + expect(resolveNode).toHaveBeenCalledExactlyOnceWith("Ubuntu", { + minimumVersion: "22.13.0", + }); + await client.dispose(); + } finally { + if (originalApiKey === undefined) delete process.env.CURSOR_API_KEY; + else process.env.CURSOR_API_KEY = originalApiKey; + } + }); + + it("surfaces deployment and boot protocol failures without hanging", async () => { + const fixture = makeProtocolFixture(); + await expect( + spawnCursorSdkWorker( + { + projectLocation: { + kind: "wsl", + distro: "Ubuntu", + linuxPath: "/work/repo", + uncPath: "\\\\wsl.localhost\\Ubuntu\\work\\repo", + }, + workerPath: fixture.path, + }, + { + resolveNode: async () => ({ + nodePath: "/usr/bin/node", + nodeVersion: "24.10.0", + source: "user-installed", + }), + deploy: () => null, + }, + ), + ).rejects.toThrow("could not be deployed"); + + const incompatible = makeProtocolFixture(99); + await expect( + spawnCursorSdkWorker({ + projectLocation: { kind: "posix", path: incompatible.directory }, + workerPath: incompatible.path, + bootTimeoutMs: 2_000, + }), + ).rejects.toThrow("protocol 99 is not supported"); + }); + + it("rejects pending requests when the worker exits", async () => { + const fixture = makeProtocolFixture(1, true); + const client = await spawnCursorSdkWorker({ + projectLocation: { kind: "posix", path: fixture.directory }, + workerPath: fixture.path, + }); + await expect( + client.initialize({ + createOptions: { + model: { id: "fixture" }, + local: { cwd: fixture.directory }, + }, + }), + ).rejects.toThrow("exited"); + await client.dispose(); + }); + + it("reports transport failure when the worker exits after acknowledging a start", async () => { + const fixture = makeProtocolFixture(1, false, true); + const client = await spawnCursorSdkWorker({ + projectLocation: { kind: "posix", path: fixture.directory }, + workerPath: fixture.path, + }); + const errors: Error[] = []; + client.onTransportError((error) => errors.push(error)); + await client.initialize({ + createOptions: { + model: { id: "fixture" }, + local: { cwd: fixture.directory }, + }, + }); + + await expect(client.start({ message: "hello" })).resolves.toEqual({ + runId: "run-fixture", + }); + await waitFor(() => errors.length === 1); + + expect(errors[0]?.message).toContain("exited"); + expect(errors[0]?.message).toContain("code 9"); + await client.dispose(); + }); + + it.runIf(process.platform !== "win32")( + "launches native workers as POSIX process-group leaders", + async () => { + const fixture = makeProtocolFixture(); + let observedOptions: SpawnOptions | undefined; + const client = await spawnCursorSdkWorker( + { + projectLocation: { kind: "posix", path: fixture.directory }, + workerPath: fixture.path, + }, + { + spawnProcess: (command, args, options) => { + observedOptions = options; + return spawn(command, args, options); + }, + }, + ); + + expect(observedOptions?.detached).toBe(true); + await client.dispose(); + }, + ); + + it.runIf(process.platform !== "win32")( + "force-kills the dedicated POSIX worker process group", + () => { + const kill = vi.spyOn(process, "kill").mockReturnValue(true); + try { + terminateCursorSdkWorkerTree({ pid: 43_210 }, true); + expect(kill).toHaveBeenCalledExactlyOnceWith(-43_210, "SIGKILL"); + } finally { + kill.mockRestore(); + } + }, + ); + + it("makes a start timeout fatal so a late send cannot orphan a run", async () => { + const fixture = makeDelayedMethodFixture("start", 250); + const client = await spawnCursorSdkWorker({ + projectLocation: { kind: "posix", path: fixture.directory }, + workerPath: fixture.path, + requestTimeoutMs: 40, + }); + const transportErrors: Error[] = []; + client.onTransportError((error) => transportErrors.push(error)); + await client.initialize({ + createOptions: { + model: { id: "fixture" }, + local: { cwd: fixture.directory }, + }, + }); + + await expect(client.start({ message: "delayed" })).rejects.toThrow( + "Cursor SDK worker request start timed out.", + ); + expect(transportErrors).toHaveLength(1); + expect(transportErrors[0]?.message).toContain("start timed out"); + await expect(client.start({ message: "second" })).rejects.toThrow( + "Cursor SDK worker is not running.", + ); + await client.dispose(); + }); + + it("makes an initialize timeout fatal so a late create cannot orphan an agent", async () => { + const fixture = makeDelayedMethodFixture("initialize", 250); + const client = await spawnCursorSdkWorker({ + projectLocation: { kind: "posix", path: fixture.directory }, + workerPath: fixture.path, + requestTimeoutMs: 40, + }); + const transportErrors: Error[] = []; + client.onTransportError((error) => transportErrors.push(error)); + + await expect( + client.initialize({ + createOptions: { + model: { id: "fixture" }, + local: { cwd: fixture.directory }, + }, + }), + ).rejects.toThrow("Cursor SDK worker request initialize timed out."); + expect(transportErrors).toHaveLength(1); + await expect(client.listModels()).rejects.toThrow("Cursor SDK worker is not running."); + await client.dispose(); + }); + + it("makes a reload timeout fatal so a late refresh cannot race the next send", async () => { + const fixture = makeDelayedMethodFixture("reload", 250); + const client = await spawnCursorSdkWorker({ + projectLocation: { kind: "posix", path: fixture.directory }, + workerPath: fixture.path, + requestTimeoutMs: 40, + }); + const transportErrors: Error[] = []; + client.onTransportError((error) => transportErrors.push(error)); + await client.initialize({ + createOptions: { + model: { id: "fixture" }, + local: { cwd: fixture.directory }, + }, + }); + + await expect(client.reload()).rejects.toThrow("Cursor SDK worker request reload timed out."); + expect(transportErrors).toHaveLength(1); + await expect(client.start({ message: "second" })).rejects.toThrow( + "Cursor SDK worker is not running.", + ); + await client.dispose(); + }); +}); + +function makeProtocolFixture( + protocolVersion = 1, + exitOnInitialize = false, + exitAfterStart = false, +): { + directory: string; + path: string; +} { + const directory = mkdtempSync(join(tmpdir(), "poracode-cursor-sdk-client-")); + tempDirectories.push(directory); + const path = join(directory, "worker.mjs"); + writeFileSync( + path, + ` +import { createInterface } from "node:readline"; +process.stdout.write("login shell banner\\n"); +process.stdout.write(JSON.stringify({ type: "ready", protocolVersion: ${protocolVersion} }) + "\\n"); +const input = createInterface({ input: process.stdin, crlfDelay: Infinity }); +input.on("line", (line) => { + const request = JSON.parse(line); + if (${exitOnInitialize} && request.method === "initialize") { + process.exit(7); + } + let result = {}; + if (request.method === "models.list") { + result = { + models: [{ id: "fixture", displayName: "Fixture" }], + sdkVersion: "1.0.24", + source: "configured", + }; + } else if (request.method === "initialize") { + result = { agentId: "agent-fixture", model: { id: "fixture" } }; + } else if (request.method === "start") { + result = { runId: "run-fixture" }; + } + process.stdout.write(JSON.stringify({ type: "response", id: request.id, ok: true, result }) + "\\n"); + if (${exitAfterStart} && request.method === "start") { + setImmediate(() => process.exit(9)); + return; + } + if (request.method === "start") { + for (const event of [ + { type: "delta", requestId: request.id, runId: "run-fixture", update: { type: "text-delta", text: "hi" } }, + { type: "message", requestId: request.id, runId: "run-fixture", message: { + type: "assistant", agent_id: "agent-fixture", run_id: "run-fixture", + message: { role: "assistant", content: [{ type: "text", text: "hi" }] }, + } }, + { type: "result", requestId: request.id, runId: "run-fixture", result: { + id: "run-fixture", status: "finished", result: "hi", + } }, + ]) { + process.stdout.write(JSON.stringify({ type: "event", event }) + "\\n"); + } + } +}); +`, + "utf8", + ); + return { directory, path }; +} + +function makeDelayedMethodFixture( + method: "initialize" | "start" | "reload", + delayMs: number, +): { + directory: string; + path: string; +} { + const directory = mkdtempSync(join(tmpdir(), "poracode-cursor-sdk-client-delayed-")); + tempDirectories.push(directory); + const path = join(directory, "worker.mjs"); + writeFileSync( + path, + ` +import { createInterface } from "node:readline"; +process.stdout.write(JSON.stringify({ type: "ready", protocolVersion: 1 }) + "\\n"); +const input = createInterface({ input: process.stdin, crlfDelay: Infinity }); +input.on("line", (line) => { + const request = JSON.parse(line); + const result = request.method === "initialize" + ? { agentId: "agent-fixture", model: { id: "fixture" } } + : request.method === "start" + ? { runId: "late-run" } + : {}; + const respond = () => process.stdout.write( + JSON.stringify({ type: "response", id: request.id, ok: true, result }) + "\\n" + ); + if (request.method === ${JSON.stringify(method)}) setTimeout(respond, ${delayMs}); + else respond(); +}); +`, + "utf8", + ); + return { directory, path }; +} + +async function waitFor(predicate: () => boolean, timeoutMs = 2_000): Promise { + const deadline = Date.now() + timeoutMs; + while (!predicate()) { + if (Date.now() >= deadline) throw new Error("Timed out waiting for client event."); + await new Promise((resolve) => setTimeout(resolve, 10)); + } +} diff --git a/src/supervisor/agents/cursor/sdkWorkerClient.ts b/src/supervisor/agents/cursor/sdkWorkerClient.ts new file mode 100644 index 000000000..1d5255439 --- /dev/null +++ b/src/supervisor/agents/cursor/sdkWorkerClient.ts @@ -0,0 +1,560 @@ +import { randomUUID } from "node:crypto"; +import { spawn, type ChildProcess, type SpawnOptions } from "node:child_process"; +import { existsSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import type { ProjectLocation } from "@/shared/contracts"; +import { terminateChildProcessTree } from "@/shared/processTree"; +import { buildAgentCommand } from "../base"; +import { + resolveNodeForDistro, + type ResolvedNode, + type ResolveNodeOptions, +} from "../../wsl/runtime"; +import { + deployFilesToWslTempBase, + resolveWslHelpersDir, + type WslDeployFile, +} from "../../wsl/wslDeploy"; +import { + CURSOR_SDK_WORKER_PROTOCOL_VERSION, + type CursorSdkWorkerAgentMessage, + type CursorSdkWorkerDiscovery, + type CursorSdkWorkerError, + type CursorSdkWorkerEvent, + type CursorSdkWorkerInitializeInput, + type CursorSdkWorkerInitializeResult, + type CursorSdkWorkerProbeResult, + type CursorSdkWorkerStartInput, + type CursorSdkWorkerStartResult, + type CursorSdkWorkerWireMessage, +} from "./sdkWorkerProtocol"; + +const DEFAULT_BOOT_TIMEOUT_MS = 15_000; +const DEFAULT_REQUEST_TIMEOUT_MS = 60_000; +const MAX_LINE_BYTES = 16 * 1024 * 1024; +const FATAL_REQUEST_TIMEOUT_METHODS = new Set(["initialize", "start", "cancel", "reload"]); + +type WslLocation = Extract; + +export interface CursorSdkWorkerSpawnOptions { + projectLocation: ProjectLocation; + /** Package root, package entry, node_modules root, or ancestor directory. */ + configuredPath?: string; + /** Test/fast-path only; path is interpreted inside the target environment. */ + sdkEntryPath?: string; + /** Required with sdkEntryPath when containment should be enforced. */ + sdkPackageRoot?: string; + /** Non-secret process environment overrides. */ + env?: Record; + /** Override the native helper path, or the host source staged into WSL. */ + workerPath?: string; + /** Override resources/wsl-helpers for WSL staging. */ + helpersDir?: string; + bootTimeoutMs?: number; + requestTimeoutMs?: number; +} + +export type CursorSdkWorkerSpawnProcess = ( + command: string, + args: readonly string[], + options: SpawnOptions, +) => ChildProcess; + +export interface CursorSdkWorkerClientDependencies { + spawnProcess?: CursorSdkWorkerSpawnProcess; + resolveNode?: (distro: string, options?: ResolveNodeOptions) => Promise; + deploy?: ( + distro: string, + baseName: string, + files: readonly WslDeployFile[], + ) => { linuxBaseDir: string } | null; +} + +export type CursorSdkWorkerEventListener = (event: CursorSdkWorkerEvent) => void; +export type CursorSdkWorkerTransportErrorListener = (error: Error) => void; + +export class CursorSdkWorkerRpcError extends Error { + readonly code: string | undefined; + + constructor(error: CursorSdkWorkerError) { + super(error.message); + this.name = error.name || "CursorSdkWorkerRpcError"; + this.code = error.code; + } +} + +interface PendingRequest { + resolve(value: unknown): void; + reject(error: Error): void; + timeout: ReturnType; +} + +interface SpawnedWorker { + child: ChildProcess; + discovery: CursorSdkWorkerDiscovery; + projectCwd: string; + useProcessGroup: boolean; +} + +export async function spawnCursorSdkWorker( + options: CursorSdkWorkerSpawnOptions, + dependencies: CursorSdkWorkerClientDependencies = {}, +): Promise { + const spawned = await spawnWorkerProcess(options, dependencies); + const client = new CursorSdkWorkerClient( + spawned.child, + spawned.discovery, + spawned.projectCwd, + options.requestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS, + spawned.useProcessGroup, + ); + try { + await client.waitUntilReady(options.bootTimeoutMs ?? DEFAULT_BOOT_TIMEOUT_MS); + return client; + } catch (error) { + client.terminate(); + throw error; + } +} + +export class CursorSdkWorkerClient { + private readonly pending = new Map(); + private readonly listeners = new Set(); + private readonly transportErrorListeners = new Set(); + private readonly ready: Promise; + private resolveReady: (() => void) | undefined; + private rejectReady: ((error: Error) => void) | undefined; + private stdoutBuffer = ""; + private readyReceived = false; + private terminated = false; + private transportError: Error | undefined; + private disposePromise: Promise | undefined; + + constructor( + private readonly child: ChildProcess, + private readonly discovery: CursorSdkWorkerDiscovery, + private readonly projectCwd: string, + private readonly requestTimeoutMs = DEFAULT_REQUEST_TIMEOUT_MS, + private readonly useProcessGroup = false, + ) { + this.ready = new Promise((resolve, reject) => { + this.resolveReady = resolve; + this.rejectReady = reject; + }); + this.attachProcess(); + } + + async initialize( + input: CursorSdkWorkerInitializeInput, + ): Promise { + return this.request("initialize", { + ...input, + sdk: this.discovery, + }); + } + + async start(input: CursorSdkWorkerStartInput): Promise { + return this.request("start", input); + } + + async cancel(runId?: string): Promise<{ cancelled: boolean }> { + return this.request<{ cancelled: boolean }>("cancel", { + ...(runId ? { runId } : {}), + }); + } + + async reload(): Promise { + await this.request("reload", {}); + } + + async listMessages( + input: { + limit?: number; + offset?: number; + } = {}, + ): Promise { + return this.request("messages.list", input); + } + + /** + * Loads/authenticates the installed SDK and fetches its account-specific + * model catalog without creating an agent. Suitable for install detection. + */ + async probe(apiKey?: string): Promise { + return this.request("models.list", { + sdk: this.discovery, + projectCwd: this.projectCwd, + ...(apiKey ? { apiKey } : {}), + }); + } + + async listModels(apiKey?: string): Promise { + return this.probe(apiKey); + } + + onEvent(listener: CursorSdkWorkerEventListener): () => void { + this.listeners.add(listener); + return () => { + this.listeners.delete(listener); + }; + } + + /** + * Subscribe to an unexpected worker/process failure. + * + * Unlike run-error events, this covers transport loss after a start request + * has already been acknowledged. Remember and replay the terminal error so + * the narrow ready→listener-registration window cannot strand a session. + * Intentional `dispose()`/`terminate()` calls remain silent. + */ + onTransportError(listener: CursorSdkWorkerTransportErrorListener): () => void { + this.transportErrorListeners.add(listener); + if (this.transportError) { + try { + listener(this.transportError); + } catch { + // Transport teardown must not be disrupted by consumer code. + } + } + return () => { + this.transportErrorListeners.delete(listener); + }; + } + + dispose(): Promise { + this.disposePromise ??= this.disposeOnce(); + return this.disposePromise; + } + + terminate(): void { + if (this.terminated) return; + this.terminated = true; + this.rejectAll(new Error("Cursor SDK worker terminated.")); + try { + terminateCursorSdkWorkerTree(this.child, this.useProcessGroup); + } catch { + // Best effort. + } + } + + async waitUntilReady(timeoutMs: number): Promise { + let timeout: ReturnType | undefined; + try { + await Promise.race([ + this.ready, + new Promise((_, reject) => { + timeout = setTimeout( + () => reject(new Error("Cursor SDK worker boot timed out.")), + timeoutMs, + ); + timeout.unref?.(); + }), + ]); + } finally { + if (timeout) clearTimeout(timeout); + } + } + + private async disposeOnce(): Promise { + if (this.terminated) return; + try { + await this.request("dispose", {}); + } catch { + // A dead worker is already disposed from the host's perspective. + } + this.child.stdin?.end(); + this.terminate(); + } + + private request(method: string, params: unknown): Promise { + if (this.terminated) { + return Promise.reject(new Error("Cursor SDK worker is not running.")); + } + if (!this.child.stdin?.writable) { + return Promise.reject(new Error("Cursor SDK worker stdin is unavailable.")); + } + const id = randomUUID(); + return new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + const error = new Error(`Cursor SDK worker request ${method} timed out.`); + if (FATAL_REQUEST_TIMEOUT_METHODS.has(method)) { + // Mutating SDK calls may still resolve after the caller's deadline. + // A fatal teardown prevents a late invisible agent/run or concurrent + // mutation of provider state after the host has already moved on. + this.fail(error); + return; + } + this.pending.delete(id); + reject(error); + }, this.requestTimeoutMs); + timeout.unref?.(); + this.pending.set(id, { + resolve: (value) => resolve(value as Result), + reject, + timeout, + }); + const payload = JSON.stringify({ type: "request", id, method, params }); + this.child.stdin!.write(`${payload}\n`, (error) => { + if (!error) return; + const pending = this.pending.get(id); + if (!pending) return; + clearTimeout(pending.timeout); + this.pending.delete(id); + pending.reject(error); + }); + }); + } + + private attachProcess(): void { + // SDK diagnostics are intentionally not forwarded: stderr can contain + // provider-native payloads, and leaving an unread pipe can deadlock a + // verbose child after its OS buffer fills. + this.child.stderr?.resume(); + this.child.stdout?.on("data", (chunk: Buffer | string) => { + this.consumeStdout(typeof chunk === "string" ? chunk : chunk.toString("utf8")); + }); + this.child.stdout?.on("error", (error) => { + this.fail(error); + }); + this.child.stdin?.on("error", (error) => { + this.fail(error); + }); + this.child.once("error", (error) => { + this.fail(error); + }); + this.child.once("exit", (code, signal) => { + if (this.terminated) return; + const suffix = signal ? ` (${signal})` : code === null ? "" : ` (code ${code})`; + this.fail(new Error(`Cursor SDK worker exited${suffix}.`)); + }); + } + + private consumeStdout(chunk: string): void { + this.stdoutBuffer += chunk; + // A UTF-16 code unit encodes to at most 3 UTF-8 bytes, so a buffer under + // MAX_LINE_BYTES/3 code units cannot exceed the limit. Measuring is O(size) + // and this runs per stdout chunk, so only confirm once the bound trips. + if ( + this.stdoutBuffer.length > MAX_LINE_BYTES / 3 && + Buffer.byteLength(this.stdoutBuffer, "utf8") > MAX_LINE_BYTES + ) { + this.fail(new Error("Cursor SDK worker emitted an oversized protocol line.")); + return; + } + let newline = this.stdoutBuffer.indexOf("\n"); + while (newline !== -1) { + const line = this.stdoutBuffer.slice(0, newline).replace(/\r$/, "").trim(); + this.stdoutBuffer = this.stdoutBuffer.slice(newline + 1); + if (line) this.consumeLine(line); + newline = this.stdoutBuffer.indexOf("\n"); + } + } + + private consumeLine(line: string): void { + let parsed: unknown; + try { + parsed = JSON.parse(line); + } catch { + // Interactive login shells may print a banner before `exec node`. + // Ignore non-protocol stdout rather than treating it as SDK data. + return; + } + if (!parsed || typeof parsed !== "object") return; + const message = parsed as CursorSdkWorkerWireMessage; + if (message.type === "ready") { + if (message.protocolVersion !== CURSOR_SDK_WORKER_PROTOCOL_VERSION) { + this.fail( + new Error( + `Cursor SDK worker protocol ${message.protocolVersion} is not supported by host protocol ${CURSOR_SDK_WORKER_PROTOCOL_VERSION}.`, + ), + ); + return; + } + if (!this.readyReceived) { + this.readyReceived = true; + this.resolveReady?.(); + this.resolveReady = undefined; + this.rejectReady = undefined; + } + return; + } + if (message.type === "response") { + const pending = this.pending.get(message.id); + if (!pending) return; + clearTimeout(pending.timeout); + this.pending.delete(message.id); + if (message.ok) pending.resolve(message.result); + else pending.reject(new CursorSdkWorkerRpcError(message.error)); + return; + } + if (message.type === "event") { + for (const listener of this.listeners) { + try { + listener(message.event); + } catch { + // One consumer cannot break transport delivery to the others. + } + } + } + } + + private fail(error: Error): void { + if (this.terminated) return; + this.terminated = true; + this.transportError = error; + this.rejectReady?.(error); + this.resolveReady = undefined; + this.rejectReady = undefined; + this.rejectAll(error); + for (const listener of this.transportErrorListeners) { + try { + listener(error); + } catch { + // One consumer cannot hide transport loss from the others. + } + } + try { + terminateCursorSdkWorkerTree(this.child, this.useProcessGroup); + } catch { + // Best effort. + } + } + + private rejectAll(error: Error): void { + for (const pending of this.pending.values()) { + clearTimeout(pending.timeout); + pending.reject(error); + } + this.pending.clear(); + } +} + +async function spawnWorkerProcess( + options: CursorSdkWorkerSpawnOptions, + dependencies: CursorSdkWorkerClientDependencies, +): Promise { + const discovery: CursorSdkWorkerDiscovery = { + ...(options.configuredPath ? { configuredPath: options.configuredPath } : {}), + ...(options.sdkEntryPath ? { entryPath: options.sdkEntryPath } : {}), + ...(options.sdkPackageRoot ? { packageRoot: options.sdkPackageRoot } : {}), + }; + const spawnProcess = + dependencies.spawnProcess ?? + ((command, args, spawnOptions) => spawn(command, args, spawnOptions)); + + if (options.projectLocation.kind === "wsl") { + return spawnWslWorker(options, discovery, spawnProcess, dependencies); + } + + const workerPath = options.workerPath ?? defaultNativeWorkerPath(); + if (!existsSync(workerPath)) { + throw new Error(`Cursor SDK worker helper is missing: ${workerPath}`); + } + const command = buildAgentCommand( + options.projectLocation, + process.execPath, + [workerPath], + process.execPath, + options.env, + ); + const useProcessGroup = process.platform !== "win32"; + const child = spawnProcess(command.command, command.args, { + cwd: command.cwd, + env: { + ...process.env, + ...command.env, + ...(process.versions.electron ? { ELECTRON_RUN_AS_NODE: "1" } : {}), + }, + stdio: ["pipe", "pipe", "pipe"], + windowsHide: true, + detached: useProcessGroup, + }); + return { + child, + discovery, + projectCwd: options.projectLocation.path, + useProcessGroup, + }; +} + +async function spawnWslWorker( + options: CursorSdkWorkerSpawnOptions, + discovery: CursorSdkWorkerDiscovery, + spawnProcess: CursorSdkWorkerSpawnProcess, + dependencies: CursorSdkWorkerClientDependencies, +): Promise { + const location = options.projectLocation as WslLocation; + const helpersDir = options.helpersDir ?? resolveWslHelpersDir(); + const workerSource = + options.workerPath ?? (helpersDir ? join(helpersDir, "cursor-sdk-worker.mjs") : ""); + if (!workerSource || !existsSync(workerSource)) { + throw new Error("Cursor SDK worker helper is unavailable for WSL."); + } + const resolveNode = dependencies.resolveNode ?? resolveNodeForDistro; + const node = await resolveNode(location.distro, { minimumVersion: "22.13.0" }); + const deploy = + dependencies.deploy ?? + ((distro, baseName, files) => deployFilesToWslTempBase(distro, baseName, files)); + const deployed = deploy(location.distro, `poracode-cursor-sdk-${process.pid}`, [ + { src: workerSource, relDest: "cursor-sdk/cursor-sdk-worker.mjs" }, + ]); + if (!deployed) { + throw new Error("Cursor SDK worker could not be deployed to WSL."); + } + const workerPath = `${deployed.linuxBaseDir}/cursor-sdk/cursor-sdk-worker.mjs`; + const safeEnv = stripApiKey(options.env); + const command = buildAgentCommand(location, node.nodePath, [workerPath], node.nodePath, safeEnv); + const useProcessGroup = process.platform !== "win32"; + const child = spawnProcess(command.command, command.args, { + env: { ...process.env, ...command.env }, + stdio: ["pipe", "pipe", "pipe"], + windowsHide: true, + detached: useProcessGroup, + }); + return { + child, + discovery, + projectCwd: location.linuxPath, + useProcessGroup, + }; +} + +/** + * Cursor's local SDK can own shell/MCP descendants. Native POSIX workers are + * launched as process-group leaders so an abnormal transport failure can + * force the whole group down. Windows keeps the shared taskkill `/T /F` + * implementation, including WSL and native SSH clients. + */ +export function terminateCursorSdkWorkerTree( + child: Pick, + useProcessGroup: boolean, +): void { + if (useProcessGroup && process.platform !== "win32" && typeof child.pid === "number") { + try { + process.kill(-child.pid, "SIGKILL"); + return; + } catch { + // A test/custom spawn may not have honored detached; fall back safely. + } + } + terminateChildProcessTree(child); +} + +function stripApiKey(env: Record | undefined): Record | undefined { + if (!env || !("CURSOR_API_KEY" in env)) return env; + const { CURSOR_API_KEY: _discarded, ...safe } = env; + return Object.keys(safe).length > 0 ? safe : undefined; +} + +function moduleDirectory(): string { + return typeof __dirname !== "undefined" ? __dirname : dirname(fileURLToPath(import.meta.url)); +} + +function unpackedAsarPath(path: string): string { + return path.replace(/([\\/])app\.asar([\\/])/, "$1app.asar.unpacked$2"); +} + +function defaultNativeWorkerPath(): string { + return join(unpackedAsarPath(moduleDirectory()), "cursorSdkWorker.mjs"); +} diff --git a/src/supervisor/agents/cursor/sdkWorkerDiagnostics.test.ts b/src/supervisor/agents/cursor/sdkWorkerDiagnostics.test.ts new file mode 100644 index 000000000..b98bb103d --- /dev/null +++ b/src/supervisor/agents/cursor/sdkWorkerDiagnostics.test.ts @@ -0,0 +1,101 @@ +import { describe, expect, it } from "vitest"; +import { CursorSdkWorkerDiagnostics } from "./sdkWorkerDiagnostics"; + +describe("Cursor SDK worker diagnostics", () => { + it("redacts credential-shaped MCP configuration without corrupting ordinary values", () => { + const diagnostics = new CursorSdkWorkerDiagnostics(); + diagnostics.rememberMcpSecrets({ + stdio: { + command: "fixture", + args: [ + "--mode", + "stdio", + "--api-key", + "argument-api-key", + "--client-secret=argument-client-secret", + "-H", + "Content-Type: application/json", + "-H", + "Authorization: Bearer argument-authorization-token", + ], + env: { + MODE: "test", + DEBUG: "1", + MCP_PRIVATE_ENV: "private-env-value", + SERVICE_API_KEY: "api-key-value", + accessToken: "camel-access-token", + clientSecret: "camel-client-secret", + DATABASE_URL: + "postgres://ordinary-user:db%20password@db.example.test/app?sslmode=require", + SENTRY_DSN: "https://dsn-public-token@errors.example.test/42", + SERVICE_URL: "https://service.example.test/path?mode=test&apiKey=query%2Fcredential", + }, + }, + http: { + type: "http", + url: "https://remote-user:remote%20password@example.test/mcp?accessToken=remote%2Ftoken", + headers: { + "Content-Type": "application/json", + "X-Test": "yes", + Authorization: "Bearer authorization-token", + "X-API-Key": "header-api-key", + }, + auth: { + CLIENT_ID: "public-client-id", + CLIENT_SECRET: "oauth-client-secret", + }, + }, + }); + + const sanitized = diagnostics.sanitizePayload({ + id: "run-1-test", + text: [ + "mode=test", + "debug=1", + "content=application/json", + "header=yes", + "argument-mode=stdio", + "argument-api-key", + "argument-client-secret", + "Content-Type: application/json", + "Bearer argument-authorization-token", + "argument-authorization-token", + "private-env-value", + "api-key-value", + "camel-access-token", + "camel-client-secret", + "ordinary-user", + "db%20password", + "db password", + "dsn-public-token", + "query%2Fcredential", + "query/credential", + "remote-user", + "remote%20password", + "remote password", + "remote%2Ftoken", + "remote/token", + "Bearer authorization-token", + "authorization-token", + "header-api-key", + "oauth-client-secret", + ].join(" / "), + }); + + expect(sanitized.id).toBe("run-1-test"); + expect(sanitized.text).toContain( + "mode=test / debug=1 / content=application/json / header=yes / argument-mode=stdio", + ); + expect(sanitized.text).toContain("Content-Type: application/json"); + expect(sanitized.text).not.toMatch( + /argument-api-key|argument-client-secret|argument-authorization-token|private-env-value|api-key-value|authorization-token|header-api-key|oauth-client-secret/u, + ); + expect(sanitized.text).not.toMatch( + /camel-access-token|camel-client-secret|ordinary-user|db(?:%20| )password|dsn-public-token|query(?:%2F|\/)credential/u, + ); + expect(sanitized.text).not.toMatch( + /remote-user|remote(?:%20| )password|remote(?:%2F|\/)token/u, + ); + expect(sanitized.text.match(/\[REDACTED\]/gu)).toHaveLength(23); + }); +}); diff --git a/src/supervisor/agents/cursor/sdkWorkerDiagnostics.ts b/src/supervisor/agents/cursor/sdkWorkerDiagnostics.ts new file mode 100644 index 000000000..7af4efdab --- /dev/null +++ b/src/supervisor/agents/cursor/sdkWorkerDiagnostics.ts @@ -0,0 +1,267 @@ +import { classifyCursorSdkRuntimeError } from "./sdkLoaderSupport"; +import type { CursorSdkRunResult } from "./sdkProtocol"; +import type { CursorSdkWorkerError, CursorSdkWorkerMcpServer } from "./sdkWorkerProtocol"; + +export class WorkerDiagnosticError extends Error { + readonly code: string; + + constructor(code: string, message: string) { + super(message); + this.name = "CursorSdkWorkerError"; + this.code = code; + } +} + +/** + * Process-local registry for credentials passed to the external SDK. + * + * Provider errors can echo transport configuration, so both thrown failures + * and normal `RunResult.error` payloads pass through this boundary. The same + * registered secrets are removed from provider payloads before they cross the + * worker protocol: MCP failures and persisted assistant/tool messages can + * otherwise echo a credential even when the SDK call itself succeeds. + */ +export class CursorSdkWorkerDiagnostics { + private readonly secrets = new Set(); + /** + * Longest-first snapshot of `secrets`, rebuilt only when a secret is added. + * Every streamed event string passes through `redact()`, so the ordering + * must not be recomputed per call. + */ + private orderedSecrets: readonly string[] = []; + + constructor() { + // The worker's environment is fixed at spawn time. + this.rememberSecret(process.env.CURSOR_API_KEY); + } + + rememberSecret(value: string | undefined): void { + if (!value?.trim() || this.secrets.has(value)) return; + this.secrets.add(value); + this.orderedSecrets = [...this.secrets].sort((left, right) => right.length - left.length); + } + + rememberMcpSecrets(servers: Record | undefined): void { + if (!servers) return; + for (const server of Object.values(servers)) { + if ("env" in server && server.env) { + for (const [name, value] of Object.entries(server.env)) { + if (isCredentialName(name)) { + this.rememberSecret(value); + } else if (isCredentialContainerName(name)) { + for (const credential of embeddedCredentials(value)) { + this.rememberSecret(credential); + } + } + } + } + if ("args" in server && server.args) { + for (const credential of argumentCredentials(server.args)) { + this.rememberSecret(credential); + } + } + if ("url" in server) { + for (const credential of embeddedCredentials(server.url)) { + this.rememberSecret(credential); + } + } + if ("headers" in server && server.headers) { + for (const [name, value] of Object.entries(server.headers)) { + if (!isCredentialName(name)) continue; + this.rememberSecret(value); + this.rememberSecret(authorizationCredential(value)); + } + } + if ("auth" in server) this.rememberSecret(server.auth?.CLIENT_SECRET); + } + } + + serializeError(error: unknown): CursorSdkWorkerError { + const record = asWorkerRecord(error); + const rawMessage = error instanceof Error ? error.message : String(error); + const classified = classifyCursorSdkRuntimeError(error); + if (classified) { + return { + name: error instanceof Error ? error.name : "CursorSdkWorkerError", + message: classified.message, + code: classified.code, + }; + } + const message = this.redact(rawMessage); + const name = + error instanceof Error + ? error.name + : typeof record?.name === "string" + ? record.name + : "Error"; + const code = + typeof record?.code === "string" + ? this.redact(record.code) + : typeof record?.status === "number" + ? String(record.status) + : undefined; + return { + name, + message, + ...(code ? { code } : {}), + }; + } + + sanitizeRunResult(result: CursorSdkRunResult): CursorSdkRunResult { + const sanitized = this.sanitizePayload(result); + const error = result.error; + if (!error) return sanitized; + + const classifiable = new Error(error.message); + if (error.code !== undefined) { + Object.assign(classifiable, { code: error.code }); + } + const classified = classifyCursorSdkRuntimeError(classifiable); + const message = classified?.message ?? this.redact(error.message); + const code = + classified?.code ?? (error.code === undefined ? undefined : this.redact(error.code)); + + return { + ...sanitized, + error: { + message, + ...(code !== undefined ? { code } : {}), + }, + }; + } + + sanitizePayload(value: T): T { + // The overwhelmingly common case on the streaming path: nothing to redact, + // so the payload does not need a deep clone at all. + if (this.orderedSecrets.length === 0) return value; + const seen = new WeakMap(); + const visit = (candidate: unknown): unknown => { + if (typeof candidate === "string") return this.redact(candidate); + if (!candidate || typeof candidate !== "object") return candidate; + const existing = seen.get(candidate); + if (existing !== undefined) return existing; + + if (Array.isArray(candidate)) { + const output: unknown[] = []; + seen.set(candidate, output); + for (const item of candidate) output.push(visit(item)); + return output; + } + + const prototype = Object.getPrototypeOf(candidate); + if (prototype !== Object.prototype && prototype !== null) return candidate; + const output: Record = {}; + seen.set(candidate, output); + for (const [key, item] of Object.entries(candidate)) output[key] = visit(item); + return output; + }; + return visit(value) as T; + } + + private redact(message: string): string { + let redacted = message; + for (const secret of this.orderedSecrets) { + redacted = redacted.replaceAll(secret, "[REDACTED]"); + } + return redacted; + } +} + +function isCredentialName(name: string): boolean { + return /(?:^|_)(?:access_?key|api_?key|auth(?:entication|orization)?|bearer|cookie|credential|password|passwd|pat|private(?:_?key)?|pwd|secret|session|token)(?:$|_)/u.test( + normalizeCredentialName(name), + ); +} + +function isCredentialContainerName(name: string): boolean { + return /(?:^|_)(?:connection_string|dsn|uri|url)(?:$|_)/u.test(normalizeCredentialName(name)); +} + +function normalizeCredentialName(name: string): string { + return name + .replace(/([a-z\d])([A-Z])/gu, "$1_$2") + .replace(/[^A-Za-z\d]+/gu, "_") + .toLowerCase(); +} + +function authorizationCredential(value: string): string | undefined { + return /^(?:basic|bearer)\s+(.+)$/iu.exec(value.trim())?.[1]; +} + +function argumentCredentials(args: readonly string[]): string[] { + const credentials = new Set(); + for (let index = 0; index < args.length; index += 1) { + const argument = args[index]!; + const assignment = /^(--?[^=\s]+)=(.*)$/u.exec(argument); + if (assignment) { + rememberArgumentCredential(credentials, assignment[1]!, assignment[2]!); + continue; + } + if (!/^--?[A-Za-z]/u.test(argument)) continue; + const value = args[index + 1]; + if (value === undefined || /^--?[A-Za-z]/u.test(value)) continue; + rememberArgumentCredential(credentials, argument, value); + } + return [...credentials]; +} + +function rememberArgumentCredential( + credentials: Set, + rawName: string, + value: string, +): void { + const name = rawName.replace(/^-+/u, ""); + if (name === "H" || normalizeCredentialName(name) === "header") { + rememberHeaderCredential(credentials, value); + } else if (isCredentialName(name)) { + rememberCredentialVariants(credentials, value); + const authorization = authorizationCredential(value); + if (authorization) rememberCredentialVariants(credentials, authorization); + } else if (isCredentialContainerName(name)) { + for (const credential of embeddedCredentials(value)) credentials.add(credential); + } +} + +function rememberHeaderCredential(credentials: Set, value: string): void { + const separator = value.indexOf(":"); + if (separator < 0 || !isCredentialName(value.slice(0, separator))) return; + const headerValue = value.slice(separator + 1).trim(); + rememberCredentialVariants(credentials, headerValue); + const authorization = authorizationCredential(headerValue); + if (authorization) rememberCredentialVariants(credentials, authorization); +} + +function embeddedCredentials(value: string): string[] { + const credentials = new Set(); + try { + const parsed = new URL(value.startsWith("jdbc:") ? value.slice("jdbc:".length) : value); + rememberCredentialVariants(credentials, parsed.username); + rememberCredentialVariants(credentials, parsed.password); + for (const [name, item] of parsed.searchParams) { + if (isCredentialName(name)) rememberCredentialVariants(credentials, item); + } + } catch { + for (const match of value.matchAll(/(?:^|[?;&\s])([A-Za-z][A-Za-z\d_.-]*)=([^?;&\s]+)/gu)) { + if (isCredentialName(match[1]!)) rememberCredentialVariants(credentials, match[2]!); + } + } + return [...credentials]; +} + +function rememberCredentialVariants(credentials: Set, value: string): void { + if (!value) return; + credentials.add(value); + try { + const decoded = decodeURIComponent(value); + credentials.add(decoded); + credentials.add(encodeURIComponent(decoded)); + } catch { + // The original value remains protected when percent-decoding is invalid. + } +} + +export function asWorkerRecord(value: unknown): Record | undefined { + return value && (typeof value === "object" || typeof value === "function") + ? (value as Record) + : undefined; +} diff --git a/src/supervisor/agents/cursor/sdkWorkerProtocol.ts b/src/supervisor/agents/cursor/sdkWorkerProtocol.ts new file mode 100644 index 000000000..3fc9897c0 --- /dev/null +++ b/src/supervisor/agents/cursor/sdkWorkerProtocol.ts @@ -0,0 +1,255 @@ +import type { + CursorSdkInteractionUpdate, + CursorSdkMessage, + CursorSdkRunResult, +} from "./sdkProtocol"; + +export const CURSOR_SDK_WORKER_PROTOCOL_VERSION = 1; + +export interface CursorSdkWorkerModelParameter { + id: string; + value: string; +} + +export interface CursorSdkWorkerModelSelection { + id: string; + params?: CursorSdkWorkerModelParameter[]; +} + +export interface CursorSdkWorkerModelParameterDefinition { + id: string; + displayName?: string; + values: Array<{ value: string; displayName?: string }>; +} + +export interface CursorSdkWorkerModelVariant { + params: CursorSdkWorkerModelParameter[]; + displayName: string; + description?: string; + isDefault?: boolean; +} + +export interface CursorSdkWorkerModel { + id: string; + displayName: string; + description?: string; + aliases?: string[]; + parameters?: CursorSdkWorkerModelParameterDefinition[]; + variants?: CursorSdkWorkerModelVariant[]; +} + +export interface CursorSdkWorkerProbeResult { + models: CursorSdkWorkerModel[]; + sdkVersion: string; + source: + | "configured" + | "project" + | "node-path" + | "global-explicit" + | "global-inferred" + | "global-npm" + | "global-pnpm" + | "explicit-entry"; +} + +export type CursorSdkWorkerMcpServer = + | { + type?: "stdio"; + command: string; + args?: string[]; + env?: Record; + cwd?: string; + } + | { + type?: "http" | "sse"; + url: string; + headers?: Record; + auth?: { + CLIENT_ID: string; + CLIENT_SECRET?: string; + scopes?: string[]; + }; + }; + +export type CursorSdkWorkerSettingSource = "project" | "user" | "team" | "mdm" | "plugins" | "all"; + +export interface CursorSdkWorkerLocalOptions { + cwd: string | string[]; + settingSources?: CursorSdkWorkerSettingSource[]; + sandboxOptions?: { enabled: boolean }; + autoReview?: boolean; + enableAgentRetries?: boolean; +} + +/** + * Serializable subset of Cursor's public AgentOptions. + * + * `local.customTools` is deliberately absent because it contains executable + * callbacks and cannot cross a process boundary. Poracode exposes external + * tools through serializable MCP server definitions instead. + */ +export interface CursorSdkWorkerAgentOptions { + model?: CursorSdkWorkerModelSelection; + name?: string; + local: CursorSdkWorkerLocalOptions; + mcpServers?: Record; + mode?: "agent" | "plan"; + agentId?: string; + idempotencyKey?: string; +} + +export type CursorSdkWorkerImage = + | { + url: string; + dimension?: { width: number; height: number }; + } + | { + data: string; + mimeType: string; + dimension?: { width: number; height: number }; + }; + +export interface CursorSdkWorkerUserMessage { + text: string; + images?: CursorSdkWorkerImage[]; +} + +export interface CursorSdkWorkerSendOptions { + model?: CursorSdkWorkerModelSelection; + mcpServers?: Record; + mode?: "agent" | "plan"; + local?: { force?: boolean }; + idempotencyKey?: string; +} + +export interface CursorSdkWorkerInitializeInput { + /** + * Normally omitted so the SDK reads CURSOR_API_KEY inside the target + * environment. Useful for callers that already hold a scoped key. + */ + apiKey?: string; + resumeAgentId?: string; + createOptions: CursorSdkWorkerAgentOptions; +} + +export interface CursorSdkWorkerInitializeResult { + agentId: string; + model?: CursorSdkWorkerModelSelection; + /** Fresh create found the deterministic local agent and resumed it instead. */ + recoveredExisting?: boolean; +} + +export interface CursorSdkWorkerStartInput { + message: string | CursorSdkWorkerUserMessage; + options?: CursorSdkWorkerSendOptions; +} + +export interface CursorSdkWorkerStartResult { + runId: string; +} + +export interface CursorSdkWorkerAgentMessage { + type: "user" | "assistant"; + uuid: string; + agent_id: string; + message: unknown; +} + +export interface CursorSdkWorkerError { + name: string; + message: string; + code?: string; +} + +export type CursorSdkWorkerEvent = + | { + type: "delta"; + requestId: string; + runId: string; + update: CursorSdkInteractionUpdate; + } + | { + type: "message"; + requestId: string; + runId: string; + message: CursorSdkMessage; + } + | { + type: "result"; + requestId: string; + runId: string; + result: CursorSdkRunResult; + } + | { + type: "run-error"; + requestId: string; + runId: string; + error: CursorSdkWorkerError; + }; + +export interface CursorSdkWorkerDiscovery { + configuredPath?: string; + /** + * Test/fast-path only. Normal callers let the worker discover the package + * inside its own execution environment. + */ + entryPath?: string; + packageRoot?: string; +} + +export interface CursorSdkWorkerInitializeParams extends CursorSdkWorkerInitializeInput { + sdk: CursorSdkWorkerDiscovery; +} + +export interface CursorSdkWorkerModelsListParams { + apiKey?: string; + sdk: CursorSdkWorkerDiscovery; + projectCwd: string; +} + +export type CursorSdkWorkerMethod = + | "initialize" + | "start" + | "cancel" + | "reload" + | "messages.list" + | "models.list" + | "dispose"; + +export type CursorSdkWorkerRequest = + | CursorSdkWorkerRequestFor<"initialize", CursorSdkWorkerInitializeParams> + | CursorSdkWorkerRequestFor<"start", CursorSdkWorkerStartInput> + | CursorSdkWorkerRequestFor<"cancel", { runId?: string }> + | CursorSdkWorkerRequestFor<"reload", Record> + | CursorSdkWorkerRequestFor<"messages.list", { limit?: number; offset?: number }> + | CursorSdkWorkerRequestFor<"models.list", CursorSdkWorkerModelsListParams> + | CursorSdkWorkerRequestFor<"dispose", Record>; + +interface CursorSdkWorkerRequestFor { + type: "request"; + id: string; + method: Method; + params: Params; +} + +export type CursorSdkWorkerWireMessage = + | { + type: "ready"; + protocolVersion: typeof CURSOR_SDK_WORKER_PROTOCOL_VERSION; + } + | { + type: "response"; + id: string; + ok: true; + result: unknown; + } + | { + type: "response"; + id: string; + ok: false; + error: CursorSdkWorkerError; + } + | { + type: "event"; + event: CursorSdkWorkerEvent; + }; diff --git a/src/supervisor/agents/cursor/sdkWorkerRuntime.ts b/src/supervisor/agents/cursor/sdkWorkerRuntime.ts new file mode 100644 index 000000000..a3c2efbed --- /dev/null +++ b/src/supervisor/agents/cursor/sdkWorkerRuntime.ts @@ -0,0 +1,285 @@ +import { isAbsolute, relative, sep } from "node:path"; +import { pathToFileURL } from "node:url"; +import { loadCursorSdk, type CursorSdkModule } from "./sdkLoader"; +import { asWorkerRecord, WorkerDiagnosticError } from "./sdkWorkerDiagnostics"; +import type { + CursorSdkWorkerAgentMessage, + CursorSdkWorkerAgentOptions, + CursorSdkWorkerDiscovery, + CursorSdkWorkerModel, + CursorSdkWorkerModelSelection, + CursorSdkWorkerProbeResult, + CursorSdkWorkerSendOptions, + CursorSdkWorkerUserMessage, +} from "./sdkWorkerProtocol"; + +const AGENT_ASYNC_DISPOSE_TIMEOUT_MS = 5_000; + +export interface RuntimeAgent { + readonly agentId: string; + readonly model?: CursorSdkWorkerModelSelection; + send( + message: string | CursorSdkWorkerUserMessage, + options?: CursorSdkWorkerSendOptions & { + onDelta?: (args: { update: unknown }) => void | Promise; + }, + ): Promise; + close(): void; + [Symbol.asyncDispose]?(): Promise; + reload(): Promise; +} + +export interface RuntimeRun { + readonly id: string; + stream(): AsyncGenerator; + wait(): Promise; + cancel(): Promise; +} + +interface RuntimeAgentApi { + create(options: CursorSdkWorkerAgentOptions & { apiKey?: string }): Promise; + resume( + agentId: string, + options?: Partial & { apiKey?: string }, + ): Promise; + messages: { + list( + agentId: string, + options?: { limit?: number; offset?: number; runtime?: "local"; cwd?: string }, + ): Promise; + }; + archive(agentId: string, options?: { cwd?: string; apiKey?: string }): Promise; + unarchive(agentId: string, options?: { cwd?: string; apiKey?: string }): Promise; + delete(agentId: string, options?: { cwd?: string; apiKey?: string }): Promise; +} + +interface RuntimeCursorApi { + models: { + list(options?: { apiKey?: string }): Promise; + }; +} + +export interface RuntimeSdkModule extends CursorSdkModule { + Agent: RuntimeAgentApi & CursorSdkModule["Agent"]; + Cursor: RuntimeCursorApi & CursorSdkModule["Cursor"]; +} + +/** + * Owns external package selection for one worker process. A worker may load + * exactly one SDK installation so native helpers and module singletons cannot + * accidentally cross package roots. + */ +export class CursorSdkWorkerRuntime { + private loadedSdk: RuntimeSdkModule | undefined; + private discoveryKey: string | undefined; + private sdkMetadata: Pick | undefined; + + get module(): RuntimeSdkModule | undefined { + return this.loadedSdk; + } + + get metadata(): Pick { + return this.sdkMetadata ?? { sdkVersion: "unknown", source: "explicit-entry" }; + } + + async load( + discovery: CursorSdkWorkerDiscovery, + projectCwd: string, + apiKey?: string, + ): Promise { + const key = JSON.stringify(discovery); + if (this.loadedSdk) { + if (this.discoveryKey !== key) { + throw new WorkerDiagnosticError( + "sdk_already_loaded", + "The Cursor SDK worker cannot switch package installations after loading.", + ); + } + return this.loadedSdk; + } + + let imported: unknown; + if (discovery.entryPath) { + validateExplicitEntry(discovery); + anchorProcessArgvAtSdk(discovery.entryPath); + imported = await import(pathToFileURL(discovery.entryPath).href); + } else { + const result = await loadCursorSdk({ + ...(discovery.configuredPath ? { configuredPath: discovery.configuredPath } : {}), + projectCwd, + ...(apiKey ? { apiKey } : {}), + env: process.env, + environment: { kind: "native" }, + }); + if (!result.ok) { + throw new WorkerDiagnosticError(result.diagnostic.code, result.diagnostic.message); + } + anchorProcessArgvAtSdk(result.value.entryPath); + imported = result.value.module; + this.sdkMetadata = { + sdkVersion: result.value.version, + source: result.value.source, + }; + } + + const validated = validateRuntimeModule(imported); + if (!validated) { + throw new WorkerDiagnosticError( + "module_api_incompatible", + "The installed package does not expose the expected public Cursor SDK API.", + ); + } + this.discoveryKey = key; + this.sdkMetadata ??= { sdkVersion: "unknown", source: "explicit-entry" }; + this.loadedSdk = validated; + return validated; + } +} + +export async function openCursorSdkAgent( + loaded: RuntimeSdkModule, + options: CursorSdkWorkerAgentOptions & { apiKey?: string }, + resumeAgentId?: string, +): Promise<{ agent: RuntimeAgent; recoveredExisting: boolean }> { + if (resumeAgentId) { + return { + agent: await loaded.Agent.resume(resumeAgentId, options), + recoveredExisting: false, + }; + } + + try { + return { + agent: await loaded.Agent.create(options), + recoveredExisting: false, + }; + } catch (error) { + const deterministicAgentId = options.agentId; + if (!deterministicAgentId || !isLocalAgentAlreadyExistsError(error, deterministicAgentId)) { + throw error; + } + const resumeOptions: Partial & { apiKey?: string } = { + ...options, + }; + delete resumeOptions.agentId; + return { + agent: await loaded.Agent.resume(deterministicAgentId, resumeOptions), + recoveredExisting: true, + }; + } +} + +export async function disposeCursorSdkAgent(currentAgent: RuntimeAgent): Promise { + const asyncDispose = currentAgent[Symbol.asyncDispose]; + if (typeof asyncDispose === "function") { + try { + const settled = await settlesWithin( + Promise.resolve(asyncDispose.call(currentAgent)), + AGENT_ASYNC_DISPOSE_TIMEOUT_MS, + ); + if (settled) return; + } catch { + // Fall through to the synchronous close fallback. + } + } + + try { + currentAgent.close(); + } catch { + // Disposal remains idempotent and best effort. + } +} + +function isLocalAgentAlreadyExistsError(error: unknown, agentId: string): boolean { + const record = asWorkerRecord(error); + const message = + error instanceof Error + ? error.message + : typeof record?.message === "string" + ? record.message + : ""; + if (message === `Agent ${agentId} already exists`) return true; + + const code = + typeof record?.code === "string" + ? record.code.trim().toUpperCase().replaceAll(/[- ]/g, "_") + : ""; + return ( + (code === "ALREADY_EXISTS" || code === "AGENT_ALREADY_EXISTS") && + /\bagent\b/i.test(message) && + message.includes(agentId) + ); +} + +/** + * Cursor SDK 1.x locates its sibling platform package by walking ancestors of + * `process.argv[1]`. The worker helper lives in Poracode (and in a staged WSL + * temp directory), so leave argv anchored at the verified external SDK entry + * before invoking any SDK API. This changes only process-local discovery; it + * neither executes nor copies the user-installed package. + */ +function anchorProcessArgvAtSdk(entryPath: string): void { + process.argv[1] = entryPath; +} + +async function settlesWithin(promise: Promise, timeoutMs: number): Promise { + let timeout: ReturnType | undefined; + try { + return await Promise.race([ + promise.then( + () => true, + () => false, + ), + new Promise((resolvePromise) => { + timeout = setTimeout(() => resolvePromise(false), timeoutMs); + timeout.unref?.(); + }), + ]); + } finally { + if (timeout) clearTimeout(timeout); + } +} + +function validateExplicitEntry(discovery: CursorSdkWorkerDiscovery): void { + const entryPath = discovery.entryPath!; + if (!isAbsolute(entryPath)) { + throw new WorkerDiagnosticError( + "configured_path_invalid", + "The explicit Cursor SDK entry path must be absolute.", + ); + } + if (!discovery.packageRoot) return; + if (!isAbsolute(discovery.packageRoot)) { + throw new WorkerDiagnosticError( + "configured_path_invalid", + "The explicit Cursor SDK package root must be absolute.", + ); + } + const fromRoot = relative(discovery.packageRoot, entryPath); + if (fromRoot === ".." || fromRoot.startsWith(`..${sep}`) || isAbsolute(fromRoot)) { + throw new WorkerDiagnosticError( + "configured_path_invalid", + "The explicit Cursor SDK entry path is outside its package root.", + ); + } +} + +function validateRuntimeModule(imported: unknown): RuntimeSdkModule | undefined { + const direct = asWorkerRecord(imported); + const fallback = asWorkerRecord(direct?.default); + for (const candidate of [direct, fallback]) { + const agentValue = asWorkerRecord(candidate?.Agent); + const cursorValue = asWorkerRecord(candidate?.Cursor); + const models = asWorkerRecord(cursorValue?.models); + const messages = asWorkerRecord(agentValue?.messages); + if ( + typeof agentValue?.create === "function" && + typeof agentValue.resume === "function" && + typeof messages?.list === "function" && + typeof models?.list === "function" + ) { + return candidate as unknown as RuntimeSdkModule; + } + } + return undefined; +} diff --git a/src/supervisor/agents/cursor/structuredRuntime.test.ts b/src/supervisor/agents/cursor/structuredRuntime.test.ts new file mode 100644 index 000000000..3de3d341c --- /dev/null +++ b/src/supervisor/agents/cursor/structuredRuntime.test.ts @@ -0,0 +1,100 @@ +import { describe, expect, it } from "vitest"; +import type { SessionRef } from "@/shared/contracts"; +import { + cursorSdkConfiguredPath, + configuredCursorStructuredRuntime, + cursorSdkSessionId, + resolveCursorStructuredRuntime, +} from "./structuredRuntime"; + +function ref(providerSessionId: string): SessionRef { + return { providerSessionId, discoveredAt: "2026-07-27T00:00:00.000Z" }; +} + +describe("configuredCursorStructuredRuntime", () => { + it("keeps ACP as the backwards-compatible default", () => { + expect(configuredCursorStructuredRuntime(undefined)).toBe("acp"); + expect(configuredCursorStructuredRuntime({ structuredRuntime: "unknown" })).toBe("acp"); + }); + + it("accepts SDK mode", () => { + expect(configuredCursorStructuredRuntime({ structuredRuntime: "sdk" })).toBe("sdk"); + }); +}); + +describe("resolveCursorStructuredRuntime", () => { + it("uses the selected runtime for a fresh thread", () => { + expect(resolveCursorStructuredRuntime({ structuredRuntime: "sdk" }, undefined)).toEqual({ + runtime: "sdk", + }); + }); + + it("pins SDK resumes independently of the new default", () => { + expect( + resolveCursorStructuredRuntime({ structuredRuntime: "acp" }, ref("sdk:agent-1")), + ).toEqual({ runtime: "sdk", providerSessionId: "agent-1" }); + }); + + it("treats historical unprefixed session ids as ACP", () => { + expect( + resolveCursorStructuredRuntime({ structuredRuntime: "sdk" }, ref("legacy-acp-id")), + ).toEqual({ + runtime: "acp", + providerSessionId: "legacy-acp-id", + }); + }); +}); + +describe("runtime-prefixed Cursor session ids", () => { + it("prefixes each runtime id exactly once", () => { + expect(cursorSdkSessionId("agent-1")).toBe("sdk:agent-1"); + expect(cursorSdkSessionId("sdk:agent-1")).toBe("sdk:agent-1"); + }); +}); + +describe("cursorSdkConfiguredPath", () => { + it("trims native paths and ignores a blank setting", () => { + expect( + cursorSdkConfiguredPath( + { sdkPackagePath: " /opt/node_modules/@cursor/sdk " }, + { kind: "posix", path: "/repo" }, + ), + ).toBe("/opt/node_modules/@cursor/sdk"); + expect( + cursorSdkConfiguredPath({ sdkPackagePath: " " }, { kind: "posix", path: "/repo" }), + ).toBeUndefined(); + }); + + it("maps a same-distro WSL UNC path but preserves native Linux paths", () => { + const location = { + kind: "wsl" as const, + distro: "Ubuntu", + linuxPath: "/home/demo/repo", + uncPath: "\\\\wsl.localhost\\Ubuntu\\home\\demo\\repo", + }; + expect( + cursorSdkConfiguredPath( + { sdkPackagePath: "\\\\wsl.localhost\\Ubuntu\\opt\\cursor-sdk" }, + location, + ), + ).toBe("/opt/cursor-sdk"); + expect(cursorSdkConfiguredPath({ sdkPackagePath: "/opt/cursor-sdk" }, location)).toBe( + "/opt/cursor-sdk", + ); + }); + + it("does not reinterpret a UNC path for another distro", () => { + const configured = "\\\\wsl.localhost\\Debian\\opt\\cursor-sdk"; + expect( + cursorSdkConfiguredPath( + { sdkPackagePath: configured }, + { + kind: "wsl", + distro: "Ubuntu", + linuxPath: "/repo", + uncPath: "\\\\wsl.localhost\\Ubuntu\\repo", + }, + ), + ).toBe(configured); + }); +}); diff --git a/src/supervisor/agents/cursor/structuredRuntime.ts b/src/supervisor/agents/cursor/structuredRuntime.ts new file mode 100644 index 000000000..2cd37569c --- /dev/null +++ b/src/supervisor/agents/cursor/structuredRuntime.ts @@ -0,0 +1,74 @@ +import type { ProjectLocation, SessionRef } from "@/shared/contracts"; +import { parseWslUncPath } from "@/shared/wsl"; + +export const CURSOR_SDK_SESSION_PREFIX = "sdk:"; + +export type CursorStructuredRuntime = "acp" | "sdk"; + +export function configuredCursorStructuredRuntime( + settings: Record | undefined, +): CursorStructuredRuntime { + switch (settings?.structuredRuntime) { + case "sdk": + return "sdk"; + default: + return "acp"; + } +} + +export interface ResolvedCursorStructuredRuntime { + runtime: CursorStructuredRuntime; + /** Provider-native id with Poracode's runtime discriminator removed. */ + providerSessionId?: string; +} + +/** + * Resume identity is authoritative over the current provider-global setting. + * That lets users change their default without accidentally loading an ACP + * chat through the SDK store (or the reverse). Historical unprefixed Cursor + * session ids are ACP ids. + */ +export function resolveCursorStructuredRuntime( + settings: Record | undefined, + sessionRef: SessionRef | undefined, +): ResolvedCursorStructuredRuntime { + const id = sessionRef?.providerSessionId; + if (id?.startsWith(CURSOR_SDK_SESSION_PREFIX)) { + return { + runtime: "sdk", + providerSessionId: id.slice(CURSOR_SDK_SESSION_PREFIX.length), + }; + } + return { + runtime: id ? "acp" : configuredCursorStructuredRuntime(settings), + ...(id ? { providerSessionId: id } : {}), + }; +} + +export function cursorSdkSessionId(providerSessionId: string): string { + return providerSessionId.startsWith(CURSOR_SDK_SESSION_PREFIX) + ? providerSessionId + : `${CURSOR_SDK_SESSION_PREFIX}${providerSessionId}`; +} + +/** + * Interpret the provider-global external package path in the execution + * environment that will import it. WSL accepts native Linux paths or a UNC + * path targeting the same distro; other host paths are left untouched so the + * in-distro loader can return its normal actionable "configured path invalid" + * diagnostic rather than guessing a drive mount. + */ +export function cursorSdkConfiguredPath( + settings: Record | undefined, + location: ProjectLocation, +): string | undefined { + const configured = + typeof settings?.sdkPackagePath === "string" ? settings.sdkPackagePath.trim() : ""; + if (!configured) return undefined; + if (location.kind !== "wsl") return configured; + if (configured.startsWith("/")) return configured; + const parsed = parseWslUncPath(configured); + return parsed && parsed.distro.toLowerCase() === location.distro.toLowerCase() + ? parsed.linuxPath + : configured; +} diff --git a/src/supervisor/agents/userMcp/translate.test.ts b/src/supervisor/agents/userMcp/translate.test.ts index 1c2a508fa..36567015d 100644 --- a/src/supervisor/agents/userMcp/translate.test.ts +++ b/src/supervisor/agents/userMcp/translate.test.ts @@ -4,6 +4,7 @@ import { buildAcpMcpServers, buildClaudeMcpServers, buildCodexMcp, + buildCursorSdkMcpServers, buildGeminiMcpServers, buildOpenCodeMcp, buildOpenCodeMcpLaunchConfig, @@ -48,12 +49,27 @@ const servers: McpServer[] = [ ]; describe("custom MCP translators", () => { - it("maps Claude, Gemini, OpenCode, and ACP transport shapes", () => { + it("maps Claude, Cursor SDK, Gemini, OpenCode, and ACP transport shapes", () => { expect(buildClaudeMcpServers(servers)).toMatchObject({ "local.tools": { type: "stdio", command: "node", timeout: 45_000 }, remote: { type: "http", url: "https://example.test/mcp", timeout: 12_500 }, events: { type: "sse", url: "https://example.test/sse" }, }); + expect(buildCursorSdkMcpServers(servers)).toEqual({ + "local.tools": { + type: "stdio", + command: "node", + args: ["server.js"], + env: { MODE: "test" }, + cwd: "/repo", + }, + remote: { + type: "http", + url: "https://example.test/mcp", + headers: { Authorization: "Bearer secret", "X-Test": "yes" }, + }, + events: { type: "sse", url: "https://example.test/sse" }, + }); expect(buildGeminiMcpServers(servers)).toMatchObject({ "local.tools": { command: "node", cwd: "/repo", timeout: 45_000 }, remote: { httpUrl: "https://example.test/mcp", timeout: 12_500 }, diff --git a/src/supervisor/agents/userMcp/translate.ts b/src/supervisor/agents/userMcp/translate.ts index 6b1a7d7b9..f92f7e832 100644 --- a/src/supervisor/agents/userMcp/translate.ts +++ b/src/supervisor/agents/userMcp/translate.ts @@ -47,6 +47,57 @@ export function buildClaudeMcpServers( ); } +export type CursorSdkMcpServerConfig = + | { + type: "stdio"; + command: string; + args?: string[]; + env?: Record; + cwd?: string; + } + | { + type: "http" | "sse"; + url: string; + headers?: Record; + }; + +/** + * Project Poracode's provider-neutral MCP descriptors into the public + * `@cursor/sdk` shape. Cursor has no per-server timeout field, so timeoutMs is + * intentionally not forwarded. OAuth client configuration is likewise absent + * here: Poracode resolves remote authorization before the provider boundary + * and supplies the resulting headers. + */ +export function buildCursorSdkMcpServers( + servers: readonly ResolvedMcpServer[], +): Record { + return Object.fromEntries( + servers.map((server) => { + const transport = server.transport; + if (transport.type === "stdio") { + return [ + server.name, + { + type: "stdio" as const, + command: transport.command, + ...(transport.args.length > 0 ? { args: transport.args } : {}), + ...(Object.keys(transport.env).length > 0 ? { env: transport.env } : {}), + ...(transport.cwd ? { cwd: transport.cwd } : {}), + }, + ]; + } + return [ + server.name, + { + type: transport.type, + url: transport.url, + ...(Object.keys(transport.headers).length > 0 ? { headers: transport.headers } : {}), + }, + ]; + }), + ); +} + export interface GeminiMcpServerConfig { command?: string; args?: string[]; diff --git a/src/supervisor/runtime/agentStatusCache.test.ts b/src/supervisor/runtime/agentStatusCache.test.ts index be37fb9e6..a0175d84c 100644 --- a/src/supervisor/runtime/agentStatusCache.test.ts +++ b/src/supervisor/runtime/agentStatusCache.test.ts @@ -219,6 +219,74 @@ describe("agent status cache", () => { expect.arrayContaining(["status", "model", "review", "compact", "permissions"]), ); }); + + it("round-trips runtime variants and session routing through the disk cache schema", () => { + const dataDir = makeTempDir(); + process.env.PORACODE_DATA_DIR = dataDir; + + const { cacheDir, statusCachePath } = resolvePoracodePaths(dataDir); + mkdirSync(cacheDir, { recursive: true }); + writeFileSync( + statusCachePath, + JSON.stringify({ + version: STATUS_CACHE_VERSION, + windows: [ + { + kind: "cursor", + label: "Cursor", + installed: true, + authState: "authenticated", + capabilities: {}, + runtimeVariants: { + sdk: { + presentationMode: "gui", + installed: false, + authState: "unknown", + authUsesProviderLogin: false, + capabilities: { + models: [{ id: "sdk-model", label: "SDK Model" }], + liveInputMode: "server", + presentationMode: "gui", + }, + }, + }, + sessionRuntimeRouting: { + prefixes: { "sdk:": "sdk" }, + fallbackRuntime: "acp", + }, + }, + ], + }), + ); + + const runtime = makeRuntime(() => {}); + const cached = ( + runtime.agentStatusService as unknown as { + readCachedStatuses: (wslDistros: readonly string[]) => { + windows: AgentStatus[]; + wsl: AgentStatus[]; + fromCache: boolean; + }; + } + ).readCachedStatuses([]); + + expect(cached.windows[0]?.runtimeVariants?.sdk).toMatchObject({ + presentationMode: "gui", + installed: false, + authState: "unknown", + authUsesProviderLogin: false, + capabilities: { + models: [{ id: "sdk-model", label: "SDK Model" }], + liveInputMode: "server", + presentationMode: "gui", + settingDefs: [], + }, + }); + expect(cached.windows[0]?.sessionRuntimeRouting).toEqual({ + prefixes: { "sdk:": "sdk" }, + fallbackRuntime: "acp", + }); + }); }); describe("detectWslAgentStatuses", () => { diff --git a/src/supervisor/runtime/agentStatusService.test.ts b/src/supervisor/runtime/agentStatusService.test.ts index ab4a95948..6e18c0af2 100644 --- a/src/supervisor/runtime/agentStatusService.test.ts +++ b/src/supervisor/runtime/agentStatusService.test.ts @@ -1,4 +1,4 @@ -import { existsSync, mkdtempSync, rmSync } from "node:fs"; +import { existsSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { afterEach, describe, expect, it, vi } from "vitest"; @@ -94,18 +94,20 @@ function makeService(detectInstall: AgentAdapter["detectInstall"]): { function makeMultiAdapterService(adapters: AgentAdapter[]): { service: AgentStatusService; statusCachePath: string; + settingsPath: string; emit: ReturnType void>>; } { const dir = makeTempDir(); const statusCachePath = join(dir, "agent-statuses.json"); + const settingsPath = join(dir, "settings.json"); const emit = vi.fn<(event: SupervisorEvent) => void>(); const service = new AgentStatusService({ adapters: new Map(adapters.map((a) => [a.kind, a])), - settingsPath: join(dir, "settings.json"), + settingsPath, statusCachePath, emit, }); - return { service, statusCachePath, emit }; + return { service, statusCachePath, settingsPath, emit }; } describe("AgentStatusService", () => { @@ -294,4 +296,53 @@ HKEY_CURRENT_USER\\Software\\Microsoft\\Windows\\CurrentVersion\\Lxss\\{333} expect.objectContaining({ kind: "codex", envKind: "wsl", envDistro: "Ubuntu" }), ); }); + + it("passes provider settings to native, WSL, and scoped detection", async () => { + const detectInstall = vi.fn().mockResolvedValue(makeStatus()); + const adapter = makeAdapter("codex", "Codex", detectInstall); + const { service, settingsPath } = makeMultiAdapterService([adapter]); + const initialSettings = { + structuredRuntime: "sdk", + sdkPackagePath: "/opt/cursor-sdk", + }; + writeFileSync( + settingsPath, + JSON.stringify({ agentSettings: { codex: initialSettings } }), + "utf8", + ); + + await service.refreshAgentStatuses({ wslDistros: ["Ubuntu"] }); + + expect(detectInstall).toHaveBeenCalledWith({ + envKind: process.platform === "win32" ? "windows" : "posix", + agentSettings: initialSettings, + }); + expect(detectInstall).toHaveBeenCalledWith({ + envKind: "wsl", + wslDistro: "Ubuntu", + agentSettings: initialSettings, + }); + + const updatedSettings = { + structuredRuntime: "acp", + sdkPackagePath: "/srv/cursor-sdk", + }; + writeFileSync( + settingsPath, + JSON.stringify({ agentSettings: { codex: updatedSettings } }), + "utf8", + ); + detectInstall.mockClear(); + + await service.refreshAgentStatuses({ + wslDistros: ["Ubuntu"], + scope: { agentKinds: ["codex"], envs: [{ kind: "wsl", distro: "Ubuntu" }] }, + }); + + expect(detectInstall).toHaveBeenCalledExactlyOnceWith({ + envKind: "wsl", + wslDistro: "Ubuntu", + agentSettings: updatedSettings, + }); + }); }); diff --git a/src/supervisor/runtime/agentStatusService.ts b/src/supervisor/runtime/agentStatusService.ts index e42e05221..1b1e63309 100644 --- a/src/supervisor/runtime/agentStatusService.ts +++ b/src/supervisor/runtime/agentStatusService.ts @@ -39,9 +39,13 @@ const execFileAsync = promisify(execFile); * `AgentStatus.preferTerminalLogin` (probe-reported; replaces the renderer's * hardcoded Grok check) and `AgentCapability.mcpScope` (adapter-declared; * replaces renderer shadow tables). - * v6 adds structured skill command metadata. + * v6 adds structured skill command metadata. v7 makes provider detection + * depend on provider-global settings (for example Cursor's selected structured + * runtime), so statuses written before that setting was supplied must not be + * reused. v8 adds independently cached runtime variants and session-id routing + * so existing threads remain pinned when a provider's default runtime changes. */ -export const STATUS_CACHE_VERSION = 6; +export const STATUS_CACHE_VERSION = 8; const WSL_AGENT_DETECTION_TIMEOUT_MS = 60_000; const WSL_LXSS_REGISTRY_KEY = "HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Lxss"; @@ -149,13 +153,19 @@ export async function detectWslAgentStatuses( distros: readonly string[], disabled?: ReadonlySet, onStatus?: (status: AgentStatus) => void, + agentSettings?: Readonly>>, ): Promise { const adapterList = [...adapters]; const statuses = await Promise.all( distros.map(async (distro) => { - const ctx: AgentEnvContext = { envKind: "wsl", wslDistro: distro }; return Promise.all( adapterList.map(async (adapter) => { + const settings = agentSettings?.[adapter.kind]; + const ctx: AgentEnvContext = { + envKind: "wsl", + wslDistro: distro, + ...(settings ? { agentSettings: settings } : {}), + }; let status: AgentStatus; if (disabled?.has(adapter.kind)) { status = { @@ -366,11 +376,14 @@ export class AgentStatusService { .filter((adapter): adapter is AgentAdapter => adapter !== undefined); const targetEnvs = this.resolveScopedEnvs(scope.envs, wslDistros); - const disabled = this.readDisabledAgents(); + const settings = this.readSettings(); + const disabled = new Set(settings.disabledAgents); const probed = await Promise.all( targetAdapters.flatMap((adapter) => - targetEnvs.map((env) => this.probeScopedStatus(adapter, env, disabled)), + targetEnvs.map((env) => + this.probeScopedStatus(adapter, env, disabled, settings.agentSettings[adapter.kind]), + ), ), ); @@ -401,6 +414,7 @@ export class AgentStatusService { adapter: AgentAdapter, env: RefreshAgentScopeEnv, disabled: ReadonlySet, + agentSettings: Record | undefined, ): Promise { const isWsl = env.kind === "wsl"; const nativeEnvKind: "windows" | "posix" = process.platform === "win32" ? "windows" : "posix"; @@ -419,11 +433,18 @@ export class AgentStatusService { ...(envDistro ? { envDistro } : {}), }; } - const ctx: AgentEnvContext | undefined = isWsl - ? { envKind: "wsl", wslDistro: env.distro } - : undefined; + const ctx: AgentEnvContext = isWsl + ? { + envKind: "wsl", + wslDistro: env.distro, + ...(agentSettings ? { agentSettings } : {}), + } + : { + envKind: nativeEnvKind, + ...(agentSettings ? { agentSettings } : {}), + }; try { - const detected = ctx ? await adapter.detectInstall(ctx) : await adapter.detectInstall(); + const detected = await adapter.detectInstall(ctx); return { ...detected, envKind, @@ -525,13 +546,12 @@ export class AgentStatusService { } } - private readDisabledAgents(): Set { + private readSettings(): ReturnType { try { const raw = readFileSync(this.options.settingsPath, "utf8"); - const settings = normalizeSharedSettings(JSON.parse(raw)); - return new Set(settings.disabledAgents); + return normalizeSharedSettings(JSON.parse(raw)); } catch { - return new Set(); + return normalizeSharedSettings({}); } } @@ -568,7 +588,8 @@ export class AgentStatusService { private async runDetection(wslDistros: readonly string[]): Promise { const adapters = [...this.options.adapters.values()]; - const disabled = this.readDisabledAgents(); + const settings = this.readSettings(); + const disabled = new Set(settings.disabledAgents); // Native detection on macOS spawns the user's interactive login shell // once per binary lookup (nvm + plugin-heavy zshrc ≈ 2-3s each). N @@ -600,7 +621,11 @@ export class AgentStatusService { }; } else { try { - const detected = await adapter.detectInstall(); + const agentSettings = settings.agentSettings[adapter.kind]; + const detected = await adapter.detectInstall({ + envKind: nativeEnvKind, + ...(agentSettings ? { agentSettings } : {}), + }); status = { ...detected, envKind: nativeEnvKind }; } catch (error) { console.error(`[supervisor] detectInstall(${adapter.kind}) failed`, error); @@ -625,9 +650,15 @@ export class AgentStatusService { return statuses; }); - const wslPromise = detectWslAgentStatuses(adapters, wslDistros, disabled, (status) => { - this.options.emit({ type: "agent-detected", status }); - }) + const wslPromise = detectWslAgentStatuses( + adapters, + wslDistros, + disabled, + (status) => { + this.options.emit({ type: "agent-detected", status }); + }, + settings.agentSettings, + ) .then((statuses) => { this.options.emit({ type: "wsl-agent-statuses", statuses }); return statuses; diff --git a/src/supervisor/runtime/pinnedNode.ts b/src/supervisor/runtime/pinnedNode.ts index 6338c76b8..999f34a46 100644 --- a/src/supervisor/runtime/pinnedNode.ts +++ b/src/supervisor/runtime/pinnedNode.ts @@ -11,9 +11,9 @@ /** * Pinned Node LTS version. Bumped manually with new LTS releases. Keep in * lockstep with `MIN_ACCEPTED_NODE_MAJOR`. After bumping, run - * `pnpm tsx scripts/refresh-node-checksums.mjs` to refresh the table below. + * `node scripts/refresh-node-checksums.mjs` to refresh the table below. */ -export const PORACODE_PINNED_NODE_VERSION = "22.11.0"; +export const PORACODE_PINNED_NODE_VERSION = "22.14.0"; /** * Minimum Node major version we accept from the user's environment. Below @@ -49,18 +49,18 @@ export type NodeTargetTriple = * `PORACODE_PINNED_NODE_VERSION`. */ export const NODE_TARBALL_CHECKSUMS: Record = { - // node-v22.11.0-linux-x64.tar.xz - "linux-x64": "83bf07dd343002a26211cf1fcd46a9d9534219aad42ee02847816940bf610a72", - // node-v22.11.0-linux-arm64.tar.xz - "linux-arm64": "6031d04b98f59ff0f7cb98566f65b115ecd893d3b7870821171708cdbaf7ae6e", - // node-v22.11.0-darwin-x64.tar.xz - "darwin-x64": "ab28d1784625d151e3f608a9412a009118f376118ed842ae643f8c2efdfb0af6", - // node-v22.11.0-darwin-arm64.tar.xz - "darwin-arm64": "c379a90c6aa605b74042a233ddcda4247b347ba5732007d280e44422cc8f9ecb", - // node-v22.11.0-win-x64.zip - "win-x64": "905373a059aecaf7f48c1ce10ffbd5334457ca00f678747f19db5ea7d256c236", - // node-v22.11.0-win-arm64.zip - "win-arm64": "b9ff5a6b6ffb68a0ffec82cc5664ed48247dabbd25ee6d129facd2f65a8ca80d", + // node-v22.14.0-linux-x64.tar.xz + "linux-x64": "69b09dba5c8dcb05c4e4273a4340db1005abeafe3927efda2bc5b249e80437ec", + // node-v22.14.0-linux-arm64.tar.xz + "linux-arm64": "08bfbf538bad0e8cbb0269f0173cca28d705874a67a22f60b57d99dc99e30050", + // node-v22.14.0-darwin-x64.tar.xz + "darwin-x64": "deb5b211c25f3f803cd49c1c3fc3964e6c3725546d7d9608d994270388dcbf02", + // node-v22.14.0-darwin-arm64.tar.xz + "darwin-arm64": "4e845cb71b4e897289312743b2e31c405a8a48720655404d82a4dce23fc43527", + // node-v22.14.0-win-x64.zip + "win-x64": "55b639295920b219bb2acbcfa00f90393a2789095b7323f79475c9f34795f217", + // node-v22.14.0-win-arm64.zip + "win-arm64": "2d71f5f9b2fffa33baa108c07d74b0d24e0c3dd8f441d567772ae0e3dd4b1a22", }; export function nodeArchiveExtension(target: NodeTargetTriple): "tar.xz" | "zip" { diff --git a/src/supervisor/wsl/runtime/index.test.ts b/src/supervisor/wsl/runtime/index.test.ts index afe004e2a..853999428 100644 --- a/src/supervisor/wsl/runtime/index.test.ts +++ b/src/supervisor/wsl/runtime/index.test.ts @@ -138,6 +138,97 @@ describe("resolveNodeForDistro", () => { expect(batchWslCommandsAsyncMock).toHaveBeenCalledTimes(1); }); + it("accepts a user-installed node that meets a consumer semver floor", async () => { + setProbe("Ubuntu", [ + { ok: true, stdout: "/home/u/.nvm/versions/node/v22.13.0/bin/node" }, + { ok: true, stdout: "v22.13.0" }, + ]); + const { resolveNodeForDistro } = await loadRuntime(); + + await expect( + resolveNodeForDistro("Ubuntu", { minimumVersion: "22.13.0" }), + ).resolves.toMatchObject({ + nodePath: "/home/u/.nvm/versions/node/v22.13.0/bin/node", + nodeVersion: "22.13.0", + source: "user-installed", + }); + }); + + it("falls back to managed node when the user node is below a consumer semver floor", async () => { + resolveWslHomeDirectoryMock.mockReturnValue("/home/u"); + resolveWslHomeDirectoryAsyncMock.mockResolvedValue("/home/u"); + setProbe( + "Ubuntu", + [ + { ok: true, stdout: "/home/u/.nvm/versions/node/v22.12.9/bin/node" }, + { ok: true, stdout: "v22.12.9" }, + ], + [{ ok: true, stdout: "x86_64" }], + ); + vi.stubGlobal("fetch", () => Promise.reject(new Error("network down"))); + const { resolveNodeForDistro } = await loadRuntime(); + + const events: string[] = []; + await expect( + resolveNodeForDistro("Ubuntu", { + minimumVersion: "22.13.0", + onProgress: (event) => + events.push( + event.kind === "probe-result" ? `${event.kind}:${event.resolved}` : event.kind, + ), + }), + ).rejects.toThrow("network down"); + expect(events).toContain("probe-result:too-old"); + }); + + it("does not reuse a cached user node that is below a later stricter floor", async () => { + resolveWslHomeDirectoryMock.mockReturnValue("/home/u"); + resolveWslHomeDirectoryAsyncMock.mockResolvedValue("/home/u"); + setProbe( + "Ubuntu", + [ + { ok: true, stdout: "/home/u/.nvm/versions/node/v22.4.0/bin/node" }, + { ok: true, stdout: "v22.4.0" }, + ], + [ + { ok: true, stdout: "/home/u/.nvm/versions/node/v22.4.0/bin/node" }, + { ok: true, stdout: "v22.4.0" }, + ], + [{ ok: true, stdout: "x86_64" }], + ); + vi.stubGlobal("fetch", () => Promise.reject(new Error("network down"))); + const { resolveNodeForDistro } = await loadRuntime(); + + await expect(resolveNodeForDistro("Ubuntu")).resolves.toMatchObject({ + nodeVersion: "22.4.0", + }); + await expect(resolveNodeForDistro("Ubuntu", { minimumVersion: "22.13.0" })).rejects.toThrow( + "network down", + ); + expect(batchWslCommandsAsyncMock).toHaveBeenCalledTimes(3); + }); + + it("rejects a malformed consumer semver floor before probing", async () => { + const { resolveNodeForDistro } = await loadRuntime(); + + await expect(resolveNodeForDistro("Ubuntu", { minimumVersion: "twenty-two" })).rejects.toThrow( + /invalid minimum Node version/, + ); + expect(batchWslCommandsAsyncMock).not.toHaveBeenCalled(); + }); + + it("fails before downloading when the requested floor exceeds the managed runtime", async () => { + setProbe("Ubuntu", [ + { ok: false, stdout: "" }, + { ok: false, stdout: "" }, + ]); + const { resolveNodeForDistro, PORACODE_PINNED_NODE_VERSION } = await loadRuntime(); + + await expect(resolveNodeForDistro("Ubuntu", { minimumVersion: "23.0.0" })).rejects.toThrow( + `Poracode-managed Node ${PORACODE_PINNED_NODE_VERSION} does not satisfy the requested minimum 23.0.0.`, + ); + }); + it("falls back to install when probed node is too old", async () => { resolveWslHomeDirectoryMock.mockReturnValue("/home/u"); resolveWslHomeDirectoryAsyncMock.mockResolvedValue("/home/u"); diff --git a/src/supervisor/wsl/runtime/index.ts b/src/supervisor/wsl/runtime/index.ts index 7297f4bfd..e4a7e3ed7 100644 --- a/src/supervisor/wsl/runtime/index.ts +++ b/src/supervisor/wsl/runtime/index.ts @@ -22,6 +22,7 @@ import { copyFileSync, existsSync, mkdirSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { promisify } from "node:util"; +import { compareVersions } from "@/shared/changelog"; import { toWslUncPath } from "@/shared/wsl"; import { batchWslCommandsAsync, @@ -90,6 +91,11 @@ export type RuntimeProgressEvent = export type RuntimeProgressListener = (event: RuntimeProgressEvent) => void; export interface ResolveNodeOptions { + /** + * Optional full semver floor for consumers with a stricter requirement + * than Poracode's general Node-major gate. + */ + minimumVersion?: string; onProgress?: RuntimeProgressListener; useBridge?: boolean; } @@ -105,9 +111,12 @@ export async function resolveNodeForDistro( distro: string, options?: ResolveNodeOptions, ): Promise { + const minimumVersion = parseMinimumNodeVersion(options?.minimumVersion); const cached = distroNodeCache.get(distro); if (cached) { - if (cached.source === "user-installed" || existsSync(toWslUncPath(distro, cached.nodePath))) { + const pathIsAvailable = + cached.source === "user-installed" || existsSync(toWslUncPath(distro, cached.nodePath)); + if (pathIsAvailable && nodeVersionIsAccepted(cached.nodeVersion, minimumVersion)) { options?.onProgress?.({ kind: "ready", nodePath: cached.nodePath }); return cached; } @@ -119,8 +128,7 @@ export async function resolveNodeForDistro( const probed = await probeUserNode(distro, { useBridge }); if (probed) { - const major = parseNodeMajor(probed.version); - if (major !== null && major >= MIN_ACCEPTED_NODE_MAJOR) { + if (nodeVersionIsAccepted(probed.version, minimumVersion)) { options?.onProgress?.({ kind: "probe-result", resolved: "found", version: probed.version }); const resolved: ResolvedNode = { nodePath: probed.nodePath, @@ -136,6 +144,7 @@ export async function resolveNodeForDistro( options?.onProgress?.({ kind: "probe-result", resolved: "missing" }); } + assertManagedNodeSatisfiesMinimum(options?.minimumVersion, minimumVersion); const installed = await installRuntimeIntoDistro(distro, options); const resolved: ResolvedNode = { nodePath: installed.nodePath, @@ -242,6 +251,8 @@ export async function installRuntimeIntoDistro( distro: string, options?: ResolveNodeOptions, ): Promise<{ nodePath: string }> { + const minimumVersion = parseMinimumNodeVersion(options?.minimumVersion); + assertManagedNodeSatisfiesMinimum(options?.minimumVersion, minimumVersion); const useBridge = options?.useBridge !== false; const arch = useBridge ? await probeDistroArch(distro) @@ -333,3 +344,52 @@ export async function installRuntimeIntoDistro( safeRm(tmpTarball); } } + +type ParsedNodeVersion = readonly [major: number, minor: number, patch: number]; + +function parseMinimumNodeVersion(version: string | undefined): ParsedNodeVersion | undefined { + if (version === undefined) return undefined; + const parsed = parseNodeVersion(version); + if (!parsed) { + throw new Error(`invalid minimum Node version "${version}"; expected a semantic version`); + } + return parsed; +} + +function parseNodeVersion(version: string): ParsedNodeVersion | undefined { + const match = /^v?(\d+)\.(\d+)(?:\.(\d+))?(?:[-+][0-9A-Za-z.-]+)?$/.exec(version.trim()); + if (!match) return undefined; + return [Number(match[1]), Number(match[2]), Number(match[3] ?? 0)]; +} + +/** + * `parseNodeVersion` already rejects malformed input, so the ordering itself + * can reuse the shared numeric-segment comparison. + */ +function compareNodeVersions(left: ParsedNodeVersion, right: ParsedNodeVersion): number { + return compareVersions(left.join("."), right.join(".")); +} + +function nodeVersionIsAccepted( + version: string, + minimumVersion: ParsedNodeVersion | undefined, +): boolean { + if (minimumVersion) { + const parsed = parseNodeVersion(version); + return parsed !== undefined && compareNodeVersions(parsed, minimumVersion) >= 0; + } + const major = parseNodeMajor(version); + return major !== null && major >= MIN_ACCEPTED_NODE_MAJOR; +} + +function assertManagedNodeSatisfiesMinimum( + requestedMinimum: string | undefined, + minimumVersion: ParsedNodeVersion | undefined, +): void { + if (!minimumVersion) return; + const managedVersion = parseNodeVersion(PORACODE_PINNED_NODE_VERSION); + if (managedVersion && compareNodeVersions(managedVersion, minimumVersion) >= 0) return; + throw new Error( + `Poracode-managed Node ${PORACODE_PINNED_NODE_VERSION} does not satisfy the requested minimum ${requestedMinimum}.`, + ); +} diff --git a/tsdown.config.ts b/tsdown.config.ts index 3f07d558e..e3b163197 100644 --- a/tsdown.config.ts +++ b/tsdown.config.ts @@ -37,6 +37,7 @@ const deps = { "node-pty", "better-sqlite3", "@anthropic-ai/claude-agent-sdk", + "@cursor/sdk", "@opencode-ai/sdk", ], }; @@ -112,6 +113,23 @@ export default defineConfig([ define: buildDefines, deps, }, + { + // Self-contained transport shell. The user-installed @cursor/sdk entry is + // discovered and dynamically imported at runtime inside this worker. + entry: { cursorSdkWorker: "src/supervisor/agents/cursor/sdkWorker.ts" }, + clean: false, + outDir: "dist/main", + platform: "node" as const, + format: "esm" as const, + // The external SDK's documented floor is Node 22.13. Keep this portable + // worker compiled for Node 22 even though Poracode itself requires Node 24. + target: "node22" as const, + sourcemap, + dts: false, + minify: false, + define: buildDefines, + deps, + }, { // Self-contained so it can be staged and executed inside a WSL distro. entry: { mcpProbeWorker: "src/supervisor/mcp/probeMcpWorker.ts" },