forked from farhoodlabs/paperclip
Add cursor_cloud adapter for Cursor SDK + Cloud Agents API v1 (#5664)
## Thinking Path
> - Paperclip orchestrates AI agents for zero-human companies
> - There are many adapter types, one per agent-runtime product (Claude,
Codex, OpenCode, Cursor local CLI, etc.)
> - Cursor shipped a public TypeScript SDK on 2026-04-29 that exposes
Cursor's full hosted-agent platform (cloud VMs, harness, MCP, skills,
hooks)
> - Paperclip had no first-class adapter for this — agents that wanted
to use Cursor's managed cloud runtime had to fall back to the local CLI
adapter, which loses the cloud session, streaming, and durable run model
> - This PR adds a new `cursor_cloud` adapter built directly on
`@cursor/sdk`, with Paperclip's heartbeat mapped to Cursor's
durable-agent + per-run model
> - The benefit is that any Paperclip agent can now drive a Cursor cloud
agent across heartbeats with native session reuse, streaming, and
cancellation, while Paperclip remains the source of truth for issue/task
state
## What Changed
- New built-in adapter package `packages/adapters/cursor-cloud` (15
files, ~1.7k LOC) backed by `@cursor/sdk` ^1.0.12
- `src/server/execute.ts` — SDK-first lifecycle: `Agent.create` /
`Agent.resume` / `Agent.getRun` / `agent.send` / `run.stream` /
`run.wait`, with session reuse keyed on the (runtime env type, env name,
repo set) tuple
- `src/server/session.ts` — codec for `cursorAgentId` + `latestRunId` +
repo metadata, persisted in `runtime.sessionParams`
- `src/server/test.ts` — environment probe via `Cursor.me()` and
optional model validation via `Cursor.models.list()`
- `src/ui/parse-stdout.ts` + `src/cli/format-event.ts` — normalize
Cursor SDK message types (`status`, `thinking`, `assistant`, `user`,
`tool_call`, `tool_result`, `result`) into Paperclip transcript events
for the UI and CLI
- Registrations: `packages/shared/src/constants.ts`,
`packages/adapter-utils/src/session-compaction.ts`,
`server/src/adapters/{registry,builtin-adapter-types}.ts`,
`ui/src/adapters/{registry,adapter-display-registry}.ts` +
`ui/src/adapters/cursor-cloud/index.ts`, `cli/src/adapters/registry.ts`,
plus workspace deps in `cli`/`server`/`ui` `package.json`
- `ui/src/components/AgentConfigForm.tsx` — hide local-Cursor
`mode`/thinking-effort field for `cursor_cloud` (different config
surface)
- 11 vitest tests covering execute paths (fresh create, matching-resume,
active-run reattach, non-finished result), session codec round-trip,
transcript parsing, and config building
## Verification
Reviewer steps:
```bash
pnpm install
pnpm --filter @paperclipai/adapter-cursor-cloud typecheck # → clean
pnpm vitest run packages/adapters/cursor-cloud # → 11/11 passing
```
End-to-end check against a real Cursor cloud agent (requires
`CURSOR_API_KEY` and Cursor GitHub-app install on the target repo):
1. Create a `cursor_cloud` agent in Paperclip with `repoUrl` set to the
test repo, `repoStartingRef: main`, and `env.CURSOR_API_KEY` set
2. Trigger a heartbeat → adapter calls `Agent.create({ cloud: { env: {
type: "cloud" }, repos: [...] } })`, streams events, terminates on
`finished`
3. Trigger a second heartbeat → adapter calls `Agent.resume` or
`agent.send` follow-up depending on prior-run state, reusing
`cursorAgentId`
4. The Paperclip UI/CLI transcript reflects Cursor `status` / `thinking`
/ `assistant` events as they stream
5. Cancellation from Paperclip maps to `run.cancel()` or Cloud API v1
`cancelRun` for cross-heartbeat cancellation
A direct-SDK smoke run against a real repo (devinfoley/my_test_project @
main) confirmed: `Cursor.me()` ok → `Agent.create` → `agent.send` →
`run.stream()` (30 events) → terminal status `finished` in ~11s.
## Risks
- **New adapter, additive only.** No existing adapter or registry is
replaced; current `cursor` local-CLI adapter is untouched. Default
behavior of any existing agent is unchanged.
- **External dependency on `@cursor/sdk`.** Cursor's SDK is v1.0.x and
may evolve. Mocked unit tests cover the public surface used here; if the
SDK breaks compatibility we update the adapter independently.
- **Cost/budget.** `cursor_cloud` runs on Cursor's billed cloud VMs;
operators must understand they are spending money outside Paperclip's
budget controls when they enable this adapter. Same shape as other
API-billed adapters.
- **No webhook support in V1.** The SDK already provides
stream/wait/cancel/reattach, so V1 does not require a public callback
URL. If a future use case needs out-of-band wakes, we add a Cloud API v1
webhook bridge as a separate change. This is called out in the issue
plan document.
- **Lockfile.** Per repo policy, `pnpm-lock.yaml` is intentionally not
in this PR — CI's lockfile workflow will update it on merge given the
manifest changes.
## Model Used
- Provider: Anthropic Claude (via Claude Code / Paperclip `claude_local`
adapter)
- Model: `claude-opus-4-7` (Claude Opus 4.7), knowledge cutoff January
2026
- Mode: standard tool-use with extended reasoning
- Context: ~200k token window
- Capabilities used: code generation, multi-file edits, shell/test
execution, GitHub PR workflow
## Checklist
- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have run tests locally and they pass (11/11 in
`packages/adapters/cursor-cloud`)
- [x] I have added or updated tests where applicable (4 new test files,
11 cases)
- [ ] If this change affects the UI, I have included before/after
screenshots (the only UI change is hiding the local-Cursor mode field on
the `cursor_cloud` adapter — happy to attach a screenshot if the
reviewer wants one)
- [x] I have updated relevant documentation to reflect my changes (issue
plan document supersedes the pre-SDK design; tracked in PAPA-203)
- [x] I have considered and documented any risks above
- [x] I will address all Greptile and reviewer comments before
requesting merge
---------
Co-authored-by: Paperclip <noreply@paperclip.ing>
This commit is contained in:
@@ -40,6 +40,7 @@ export const LEGACY_SESSIONED_ADAPTER_TYPES = new Set([
|
||||
"acpx_local",
|
||||
"claude_local",
|
||||
"codex_local",
|
||||
"cursor_cloud",
|
||||
"cursor",
|
||||
"gemini_local",
|
||||
"hermes_local",
|
||||
@@ -63,6 +64,11 @@ export const ADAPTER_SESSION_MANAGEMENT: Record<string, AdapterSessionManagement
|
||||
nativeContextManagement: "confirmed",
|
||||
defaultSessionCompaction: ADAPTER_MANAGED_SESSION_POLICY,
|
||||
},
|
||||
cursor_cloud: {
|
||||
supportsSessionResume: true,
|
||||
nativeContextManagement: "unknown",
|
||||
defaultSessionCompaction: DEFAULT_SESSION_COMPACTION_POLICY,
|
||||
},
|
||||
cursor: {
|
||||
supportsSessionResume: true,
|
||||
nativeContextManagement: "unknown",
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
{
|
||||
"name": "@paperclipai/adapter-cursor-cloud",
|
||||
"version": "0.3.1",
|
||||
"license": "MIT",
|
||||
"homepage": "https://github.com/paperclipai/paperclip",
|
||||
"bugs": {
|
||||
"url": "https://github.com/paperclipai/paperclip/issues"
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/paperclipai/paperclip",
|
||||
"directory": "packages/adapters/cursor-cloud"
|
||||
},
|
||||
"type": "module",
|
||||
"exports": {
|
||||
".": "./src/index.ts",
|
||||
"./server": "./src/server/index.ts",
|
||||
"./ui": "./src/ui/index.ts",
|
||||
"./cli": "./src/cli/index.ts"
|
||||
},
|
||||
"publishConfig": {
|
||||
"access": "public",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"import": "./dist/index.js"
|
||||
},
|
||||
"./server": {
|
||||
"types": "./dist/server/index.d.ts",
|
||||
"import": "./dist/server/index.js"
|
||||
},
|
||||
"./ui": {
|
||||
"types": "./dist/ui/index.d.ts",
|
||||
"import": "./dist/ui/index.js"
|
||||
},
|
||||
"./cli": {
|
||||
"types": "./dist/cli/index.d.ts",
|
||||
"import": "./dist/cli/index.js"
|
||||
}
|
||||
},
|
||||
"main": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts"
|
||||
},
|
||||
"files": [
|
||||
"dist"
|
||||
],
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"clean": "rm -rf dist",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@cursor/sdk": "^1.0.12",
|
||||
"@paperclipai/adapter-utils": "workspace:*",
|
||||
"picocolors": "^1.1.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^24.6.0",
|
||||
"typescript": "^5.7.3"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import pc from "picocolors";
|
||||
import { parseCursorCloudStdoutLine } from "../ui/parse-stdout.js";
|
||||
|
||||
export function printCursorCloudEvent(raw: string, _debug: boolean): void {
|
||||
const entries = parseCursorCloudStdoutLine(raw, new Date().toISOString());
|
||||
for (const entry of entries) {
|
||||
switch (entry.kind) {
|
||||
case "assistant":
|
||||
console.log(pc.green(`assistant: ${entry.text}`));
|
||||
break;
|
||||
case "thinking":
|
||||
console.log(pc.gray(`thinking: ${entry.text}`));
|
||||
break;
|
||||
case "user":
|
||||
console.log(pc.gray(`user: ${entry.text}`));
|
||||
break;
|
||||
case "tool_call":
|
||||
console.log(pc.yellow(`tool_call: ${entry.name}`));
|
||||
break;
|
||||
case "tool_result":
|
||||
console.log((entry.isError ? pc.red : pc.cyan)(entry.content || "tool result"));
|
||||
break;
|
||||
case "result":
|
||||
console.log((entry.isError ? pc.red : pc.blue)(`result: ${entry.subtype}${entry.text ? ` - ${entry.text}` : ""}`));
|
||||
break;
|
||||
case "stderr":
|
||||
console.error(pc.red(entry.text));
|
||||
break;
|
||||
case "system":
|
||||
console.log(pc.blue(entry.text));
|
||||
break;
|
||||
case "init":
|
||||
console.log(pc.blue(`Cursor Cloud init (${entry.sessionId})`));
|
||||
break;
|
||||
case "stdout":
|
||||
console.log(entry.text);
|
||||
break;
|
||||
default:
|
||||
console.log("text" in entry ? entry.text : JSON.stringify(entry));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { printCursorCloudEvent } from "./format-event.js";
|
||||
@@ -0,0 +1,34 @@
|
||||
export const type = "cursor_cloud";
|
||||
export const label = "Cursor Cloud";
|
||||
|
||||
export const agentConfigurationDoc = `# cursor_cloud agent configuration
|
||||
|
||||
Adapter: cursor_cloud
|
||||
|
||||
Use when:
|
||||
- You want Paperclip to run Cursor Cloud Agents through the official Cursor SDK
|
||||
- You want durable remote Cursor agent sessions across Paperclip heartbeats
|
||||
- You want Paperclip to keep task state while Cursor handles remote code execution
|
||||
|
||||
Core fields:
|
||||
- repoUrl (string, required): Git repository URL Cursor should open
|
||||
- repoStartingRef (string, optional): starting ref for the repo
|
||||
- repoPullRequestUrl (string, optional): PR URL to attach the agent to
|
||||
- runtimeEnvType (string, optional): cloud | pool | machine
|
||||
- runtimeEnvName (string, optional): named cloud/pool/machine target
|
||||
- workOnCurrentBranch (boolean, optional): continue work on current branch
|
||||
- autoCreatePR (boolean, optional): let Cursor auto-create a PR
|
||||
- skipReviewerRequest (boolean, optional): suppress reviewer request on auto-created PRs
|
||||
- instructionsFilePath (string, optional): agent instructions file prepended to the prompt
|
||||
- promptTemplate (string, optional): heartbeat prompt template
|
||||
- bootstrapPromptTemplate (string, optional): first-run-only bootstrap prompt template
|
||||
- model (string, optional): Cursor model id; omit to use the account default
|
||||
- env.CURSOR_API_KEY (string, required): Cursor API key
|
||||
- env.* (optional): additional env vars injected into the cloud agent shell
|
||||
|
||||
Notes:
|
||||
- Paperclip reuses the durable Cursor agent across heartbeats when the repo/runtime identity still matches.
|
||||
- Each Paperclip heartbeat maps to a Cursor run on that durable agent.
|
||||
- Paperclip injects PAPERCLIP_* runtime env vars into the cloud agent shell through Cursor SDK cloud envVars.
|
||||
- Paperclip remains the source of truth for issue/task state; Cursor provides the remote execution surface.
|
||||
`;
|
||||
@@ -0,0 +1,348 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { AdapterExecutionContext } from "@paperclipai/adapter-utils";
|
||||
import { execute } from "./execute.js";
|
||||
|
||||
type MockRunOptions = {
|
||||
id?: string;
|
||||
agentId?: string;
|
||||
status?: string;
|
||||
waitResult?: Record<string, unknown>;
|
||||
streamMessages?: unknown[];
|
||||
streamError?: Error | null;
|
||||
};
|
||||
|
||||
type MockAgentOptions = {
|
||||
agentId?: string;
|
||||
sendRun?: ReturnType<typeof createMockRun>;
|
||||
};
|
||||
|
||||
const { createMock, resumeMock, getRunMock } = vi.hoisted(() => ({
|
||||
createMock: vi.fn(),
|
||||
resumeMock: vi.fn(),
|
||||
getRunMock: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@cursor/sdk", () => ({
|
||||
Agent: {
|
||||
create: createMock,
|
||||
resume: resumeMock,
|
||||
getRun: getRunMock,
|
||||
},
|
||||
}));
|
||||
|
||||
function createMockRun(options: MockRunOptions = {}) {
|
||||
const runId = options.id ?? "run-123";
|
||||
const agentId = options.agentId ?? "agent-123";
|
||||
const status = options.status ?? "finished";
|
||||
const waitResult = options.waitResult ?? {
|
||||
id: runId,
|
||||
status,
|
||||
result: "Done\nWith detail",
|
||||
model: { id: "gpt-5.4" },
|
||||
durationMs: 1234,
|
||||
};
|
||||
const streamMessages = options.streamMessages ?? [];
|
||||
const streamError = options.streamError ?? null;
|
||||
|
||||
return {
|
||||
id: runId,
|
||||
agentId,
|
||||
status,
|
||||
result: typeof waitResult.result === "string" ? waitResult.result : null,
|
||||
model: waitResult.model ?? null,
|
||||
durationMs: waitResult.durationMs ?? null,
|
||||
git: waitResult.git ?? null,
|
||||
supports(capability: string) {
|
||||
return capability === "stream" || capability === "wait";
|
||||
},
|
||||
async *stream() {
|
||||
for (const message of streamMessages) yield message;
|
||||
if (streamError) throw streamError;
|
||||
},
|
||||
async wait() {
|
||||
return waitResult;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function createMockSdkAgent(options: MockAgentOptions = {}) {
|
||||
const sendRun = options.sendRun ?? createMockRun();
|
||||
return {
|
||||
agentId: options.agentId ?? sendRun.agentId,
|
||||
send: vi.fn(async () => sendRun),
|
||||
[Symbol.asyncDispose]: vi.fn(async () => {}),
|
||||
};
|
||||
}
|
||||
|
||||
function createContext(
|
||||
overrides: Partial<AdapterExecutionContext> = {},
|
||||
): AdapterExecutionContext & {
|
||||
logs: Array<{ stream: "stdout" | "stderr"; chunk: string }>;
|
||||
meta: Record<string, unknown>[];
|
||||
} {
|
||||
const logs: Array<{ stream: "stdout" | "stderr"; chunk: string }> = [];
|
||||
const meta: Record<string, unknown>[] = [];
|
||||
const agent = overrides.agent ?? {
|
||||
id: "agent-1",
|
||||
companyId: "company-1",
|
||||
name: "Cursor Cloud Agent",
|
||||
adapterType: "cursor_cloud",
|
||||
adapterConfig: {},
|
||||
};
|
||||
const runtime = overrides.runtime ?? {
|
||||
sessionId: null,
|
||||
sessionParams: null,
|
||||
sessionDisplayId: null,
|
||||
taskKey: null,
|
||||
};
|
||||
const config = overrides.config ?? {
|
||||
env: {
|
||||
CURSOR_API_KEY: "cursor-secret",
|
||||
EXTRA_FLAG: "1",
|
||||
},
|
||||
repoUrl: "https://github.com/paperclipai/paperclip.git",
|
||||
repoStartingRef: "main",
|
||||
runtimeEnvType: "cloud",
|
||||
promptTemplate: "Do the work for {{agent.name}}",
|
||||
model: "gpt-5.4",
|
||||
};
|
||||
const context = overrides.context ?? {
|
||||
taskId: "issue-1",
|
||||
issueId: "issue-1",
|
||||
wakeReason: "issue_commented",
|
||||
};
|
||||
|
||||
const base: AdapterExecutionContext = {
|
||||
runId: "run-heartbeat-1",
|
||||
agent,
|
||||
runtime,
|
||||
config,
|
||||
context,
|
||||
authToken: "paperclip-run-jwt",
|
||||
onLog: async (stream, chunk) => {
|
||||
logs.push({ stream, chunk });
|
||||
},
|
||||
onMeta: async (entry) => {
|
||||
meta.push(entry as unknown as Record<string, unknown>);
|
||||
},
|
||||
};
|
||||
|
||||
return {
|
||||
...base,
|
||||
...overrides,
|
||||
logs,
|
||||
meta,
|
||||
};
|
||||
}
|
||||
|
||||
describe("cursor_cloud execute", () => {
|
||||
beforeEach(() => {
|
||||
createMock.mockReset();
|
||||
resumeMock.mockReset();
|
||||
getRunMock.mockReset();
|
||||
});
|
||||
|
||||
it("creates a fresh Cursor agent and injects Paperclip env without CURSOR_API_KEY", async () => {
|
||||
const run = createMockRun({
|
||||
agentId: "agent-fresh",
|
||||
streamMessages: [
|
||||
{
|
||||
type: "assistant",
|
||||
message: {
|
||||
content: [{ type: "text", text: "Working" }],
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
const sdkAgent = createMockSdkAgent({ agentId: "agent-fresh", sendRun: run });
|
||||
createMock.mockResolvedValue(sdkAgent);
|
||||
const ctx = createContext();
|
||||
|
||||
const result = await execute(ctx);
|
||||
|
||||
expect(createMock).toHaveBeenCalledTimes(1);
|
||||
expect(resumeMock).not.toHaveBeenCalled();
|
||||
expect(getRunMock).not.toHaveBeenCalled();
|
||||
expect(createMock.mock.calls[0]?.[0]).toMatchObject({
|
||||
apiKey: "cursor-secret",
|
||||
name: "Paperclip Cursor Cloud Agent",
|
||||
model: { id: "gpt-5.4" },
|
||||
cloud: {
|
||||
env: { type: "cloud" },
|
||||
repos: [{ url: "https://github.com/paperclipai/paperclip.git", startingRef: "main" }],
|
||||
},
|
||||
});
|
||||
expect(createMock.mock.calls[0]?.[0]?.cloud?.envVars).toMatchObject({
|
||||
EXTRA_FLAG: "1",
|
||||
PAPERCLIP_RUN_ID: "run-heartbeat-1",
|
||||
PAPERCLIP_TASK_ID: "issue-1",
|
||||
PAPERCLIP_WAKE_REASON: "issue_commented",
|
||||
PAPERCLIP_API_KEY: "paperclip-run-jwt",
|
||||
});
|
||||
expect(createMock.mock.calls[0]?.[0]?.cloud?.envVars).not.toHaveProperty("CURSOR_API_KEY");
|
||||
|
||||
expect(result).toMatchObject({
|
||||
exitCode: 0,
|
||||
errorMessage: null,
|
||||
sessionId: "agent-fresh",
|
||||
model: "gpt-5.4",
|
||||
summary: "Done",
|
||||
sessionParams: {
|
||||
cursorAgentId: "agent-fresh",
|
||||
latestRunId: "run-123",
|
||||
runtime: "cloud",
|
||||
envType: "cloud",
|
||||
repos: [{ url: "https://github.com/paperclipai/paperclip.git", startingRef: "main" }],
|
||||
},
|
||||
});
|
||||
expect(ctx.logs.map((entry) => entry.chunk)).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.stringContaining('"type":"cursor_cloud.init"'),
|
||||
expect.stringContaining('"type":"cursor_cloud.message"'),
|
||||
expect.stringContaining('"type":"cursor_cloud.result"'),
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it("resumes a matching saved session when no active run can be reattached", async () => {
|
||||
getRunMock.mockResolvedValue(createMockRun({ status: "finished" }));
|
||||
const resumedRun = createMockRun({ id: "run-resumed", agentId: "agent-resumed" });
|
||||
const sdkAgent = createMockSdkAgent({ agentId: "agent-resumed", sendRun: resumedRun });
|
||||
resumeMock.mockResolvedValue(sdkAgent);
|
||||
const ctx = createContext({
|
||||
runtime: {
|
||||
sessionId: null,
|
||||
sessionDisplayId: "agent-previous",
|
||||
taskKey: null,
|
||||
sessionParams: {
|
||||
cursorAgentId: "agent-previous",
|
||||
latestRunId: "run-previous",
|
||||
runtime: "cloud",
|
||||
envType: "cloud",
|
||||
repos: [{ url: "https://github.com/paperclipai/paperclip.git", startingRef: "main" }],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const result = await execute(ctx);
|
||||
|
||||
expect(getRunMock).toHaveBeenCalledWith("run-previous", {
|
||||
runtime: "cloud",
|
||||
agentId: "agent-previous",
|
||||
apiKey: "cursor-secret",
|
||||
});
|
||||
expect(resumeMock).toHaveBeenCalledTimes(1);
|
||||
expect(createMock).not.toHaveBeenCalled();
|
||||
expect(sdkAgent.send).toHaveBeenCalledTimes(1);
|
||||
expect(result.sessionId).toBe("agent-resumed");
|
||||
});
|
||||
|
||||
it("reattaches to an active run, drains it, then sends the heartbeat as a follow-up", async () => {
|
||||
const attachedRun = createMockRun({
|
||||
id: "run-attached",
|
||||
agentId: "agent-attached",
|
||||
status: "running",
|
||||
waitResult: {
|
||||
id: "run-attached",
|
||||
status: "finished",
|
||||
result: "Prior result",
|
||||
model: { id: "gpt-5.4" },
|
||||
},
|
||||
streamMessages: [
|
||||
{
|
||||
type: "status",
|
||||
status: "running",
|
||||
message: "Still working",
|
||||
},
|
||||
],
|
||||
});
|
||||
getRunMock.mockResolvedValue(attachedRun);
|
||||
const followUpRun = createMockRun({
|
||||
id: "run-followup",
|
||||
agentId: "agent-attached",
|
||||
waitResult: {
|
||||
id: "run-followup",
|
||||
status: "finished",
|
||||
result: "Follow-up result",
|
||||
model: { id: "gpt-5.4" },
|
||||
},
|
||||
});
|
||||
const sdkAgent = createMockSdkAgent({ agentId: "agent-attached", sendRun: followUpRun });
|
||||
resumeMock.mockResolvedValue(sdkAgent);
|
||||
const ctx = createContext({
|
||||
runtime: {
|
||||
sessionId: null,
|
||||
sessionDisplayId: "agent-attached",
|
||||
taskKey: null,
|
||||
sessionParams: {
|
||||
cursorAgentId: "agent-attached",
|
||||
latestRunId: "run-attached",
|
||||
runtime: "cloud",
|
||||
envType: "cloud",
|
||||
repos: [{ url: "https://github.com/paperclipai/paperclip.git", startingRef: "main" }],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const result = await execute(ctx);
|
||||
|
||||
expect(getRunMock).toHaveBeenCalledTimes(1);
|
||||
expect(createMock).not.toHaveBeenCalled();
|
||||
expect(resumeMock).toHaveBeenCalledTimes(1);
|
||||
expect(sdkAgent.send).toHaveBeenCalledTimes(1);
|
||||
expect(result).toMatchObject({
|
||||
exitCode: 0,
|
||||
sessionId: "agent-attached",
|
||||
summary: "Follow-up result",
|
||||
resultJson: {
|
||||
cursorRunId: "run-followup",
|
||||
},
|
||||
});
|
||||
const logChunks = ctx.logs.map((entry) => entry.chunk);
|
||||
expect(logChunks).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.stringContaining("Reattached to existing Cursor run run-attached."),
|
||||
expect.stringContaining("Prior Cursor run run-attached finished"),
|
||||
expect.stringContaining("Started Cursor run run-followup."),
|
||||
expect.stringContaining('"runId":"run-attached"'),
|
||||
expect.stringContaining('"runId":"run-followup"'),
|
||||
]),
|
||||
);
|
||||
expect(ctx.meta[0]?.context).toMatchObject({
|
||||
cursorCloud: {
|
||||
canReuseSession: true,
|
||||
repoUrl: "https://github.com/paperclipai/paperclip.git",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("maps non-finished Cursor results to failing Paperclip runs", async () => {
|
||||
const cancelledRun = createMockRun({
|
||||
id: "run-cancelled",
|
||||
agentId: "agent-cancelled",
|
||||
status: "cancelled",
|
||||
waitResult: {
|
||||
id: "run-cancelled",
|
||||
status: "cancelled",
|
||||
result: "",
|
||||
model: { id: "gpt-5.4" },
|
||||
},
|
||||
});
|
||||
const sdkAgent = createMockSdkAgent({ agentId: "agent-cancelled", sendRun: cancelledRun });
|
||||
createMock.mockResolvedValue(sdkAgent);
|
||||
const ctx = createContext();
|
||||
|
||||
const result = await execute(ctx);
|
||||
|
||||
expect(result).toMatchObject({
|
||||
exitCode: 1,
|
||||
errorMessage: "Cursor run cancelled",
|
||||
sessionId: "agent-cancelled",
|
||||
resultJson: {
|
||||
status: "cancelled",
|
||||
cursorAgentId: "agent-cancelled",
|
||||
cursorRunId: "run-cancelled",
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,607 @@
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import {
|
||||
Agent,
|
||||
type AgentOptions,
|
||||
type ModelSelection,
|
||||
type Run,
|
||||
type RunResult,
|
||||
type SDKAgent,
|
||||
type SDKMessage,
|
||||
} from "@cursor/sdk";
|
||||
import type { AdapterExecutionContext, AdapterExecutionResult, AdapterInvocationMeta } from "@paperclipai/adapter-utils";
|
||||
import {
|
||||
DEFAULT_PAPERCLIP_AGENT_PROMPT_TEMPLATE,
|
||||
asBoolean,
|
||||
asString,
|
||||
buildPaperclipEnv,
|
||||
joinPromptSections,
|
||||
parseObject,
|
||||
readPaperclipIssueWorkModeFromContext,
|
||||
renderPaperclipWakePrompt,
|
||||
renderTemplate,
|
||||
stringifyPaperclipWakePayload,
|
||||
} from "@paperclipai/adapter-utils/server-utils";
|
||||
|
||||
type CursorCloudSession = {
|
||||
cursorAgentId: string;
|
||||
latestRunId?: string;
|
||||
runtime: "cloud";
|
||||
envType?: "cloud" | "pool" | "machine";
|
||||
envName?: string;
|
||||
repos: Array<{ url: string; startingRef?: string; prUrl?: string }>;
|
||||
};
|
||||
|
||||
type CursorCloudEvent =
|
||||
| { type: "cursor_cloud.init"; sessionId: string; agentId: string; runId?: string; model?: string }
|
||||
| { type: "cursor_cloud.status"; status: string; message?: string }
|
||||
| { type: "cursor_cloud.message"; message: SDKMessage }
|
||||
| {
|
||||
type: "cursor_cloud.result";
|
||||
status: string;
|
||||
result?: string;
|
||||
model?: string;
|
||||
durationMs?: number;
|
||||
git?: unknown;
|
||||
error?: string;
|
||||
};
|
||||
|
||||
function asRecord(value: unknown): Record<string, unknown> | null {
|
||||
if (typeof value !== "object" || value === null || Array.isArray(value)) return null;
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function asStringEnvMap(value: unknown): Record<string, string> {
|
||||
const parsed = parseObject(value);
|
||||
const env: Record<string, string> = {};
|
||||
for (const [key, entry] of Object.entries(parsed)) {
|
||||
if (typeof entry === "string") {
|
||||
env[key] = entry;
|
||||
} else if (typeof entry === "object" && entry !== null && !Array.isArray(entry)) {
|
||||
const rec = entry as Record<string, unknown>;
|
||||
if (rec.type === "plain" && typeof rec.value === "string") env[key] = rec.value;
|
||||
}
|
||||
}
|
||||
return env;
|
||||
}
|
||||
|
||||
function normalizeEnvType(raw: string): "cloud" | "pool" | "machine" {
|
||||
const value = raw.trim().toLowerCase();
|
||||
if (value === "pool" || value === "machine") return value;
|
||||
return "cloud";
|
||||
}
|
||||
|
||||
function trimNullable(value: unknown): string | null {
|
||||
return typeof value === "string" && value.trim().length > 0 ? value.trim() : null;
|
||||
}
|
||||
|
||||
function firstNonEmptyLine(text: string): string {
|
||||
return (
|
||||
text
|
||||
.split(/\r?\n/)
|
||||
.map((line) => line.trim())
|
||||
.find(Boolean) ?? ""
|
||||
);
|
||||
}
|
||||
|
||||
function toModelSelection(rawModel: string): ModelSelection | undefined {
|
||||
const model = rawModel.trim();
|
||||
return model ? { id: model } : undefined;
|
||||
}
|
||||
|
||||
function toSummary(result: RunResult): string | null {
|
||||
const direct = trimNullable(result.result);
|
||||
if (direct) return firstNonEmptyLine(direct);
|
||||
return null;
|
||||
}
|
||||
|
||||
function formatRunError(err: unknown): string {
|
||||
if (err instanceof Error && err.message.trim().length > 0) return err.message.trim();
|
||||
return String(err);
|
||||
}
|
||||
|
||||
function buildWakeEnv(ctx: AdapterExecutionContext, configEnv: Record<string, string>): Record<string, string> {
|
||||
const { runId, agent, context, authToken } = ctx;
|
||||
const env: Record<string, string> = {
|
||||
...configEnv,
|
||||
...buildPaperclipEnv(agent),
|
||||
PAPERCLIP_RUN_ID: runId,
|
||||
};
|
||||
|
||||
const wakeTaskId = trimNullable(context.taskId) ?? trimNullable(context.issueId);
|
||||
const wakeReason = trimNullable(context.wakeReason);
|
||||
const wakeCommentId = trimNullable(context.wakeCommentId) ?? trimNullable(context.commentId);
|
||||
const approvalId = trimNullable(context.approvalId);
|
||||
const approvalStatus = trimNullable(context.approvalStatus);
|
||||
const linkedIssueIds = Array.isArray(context.issueIds)
|
||||
? context.issueIds.filter((value): value is string => typeof value === "string" && value.trim().length > 0)
|
||||
: [];
|
||||
const wakePayloadJson = stringifyPaperclipWakePayload(context.paperclipWake);
|
||||
const issueWorkMode = readPaperclipIssueWorkModeFromContext(context);
|
||||
|
||||
if (wakeTaskId) env.PAPERCLIP_TASK_ID = wakeTaskId;
|
||||
if (wakeReason) env.PAPERCLIP_WAKE_REASON = wakeReason;
|
||||
if (wakeCommentId) env.PAPERCLIP_WAKE_COMMENT_ID = wakeCommentId;
|
||||
if (approvalId) env.PAPERCLIP_APPROVAL_ID = approvalId;
|
||||
if (approvalStatus) env.PAPERCLIP_APPROVAL_STATUS = approvalStatus;
|
||||
if (linkedIssueIds.length > 0) env.PAPERCLIP_LINKED_ISSUE_IDS = linkedIssueIds.join(",");
|
||||
if (wakePayloadJson) env.PAPERCLIP_WAKE_PAYLOAD_JSON = wakePayloadJson;
|
||||
if (issueWorkMode) env.PAPERCLIP_ISSUE_WORK_MODE = issueWorkMode;
|
||||
if (!trimNullable(env.PAPERCLIP_API_KEY) && authToken) {
|
||||
env.PAPERCLIP_API_KEY = authToken;
|
||||
}
|
||||
|
||||
const workspace = parseObject(context.paperclipWorkspace);
|
||||
const workspaceMappings: Array<[string, unknown]> = [
|
||||
["PAPERCLIP_WORKSPACE_CWD", workspace.cwd],
|
||||
["PAPERCLIP_WORKSPACE_SOURCE", workspace.source],
|
||||
["PAPERCLIP_WORKSPACE_ID", workspace.workspaceId],
|
||||
["PAPERCLIP_WORKSPACE_REPO_URL", workspace.repoUrl],
|
||||
["PAPERCLIP_WORKSPACE_REPO_REF", workspace.repoRef],
|
||||
["PAPERCLIP_WORKSPACE_BRANCH", workspace.branch],
|
||||
["PAPERCLIP_WORKSPACE_WORKTREE_PATH", workspace.worktreePath],
|
||||
["AGENT_HOME", workspace.agentHome],
|
||||
];
|
||||
for (const [key, value] of workspaceMappings) {
|
||||
const normalized = trimNullable(value);
|
||||
if (normalized) env[key] = normalized;
|
||||
}
|
||||
|
||||
delete env.CURSOR_API_KEY;
|
||||
return env;
|
||||
}
|
||||
|
||||
async function buildInstructionsPrefix(
|
||||
config: Record<string, unknown>,
|
||||
onLog: AdapterExecutionContext["onLog"],
|
||||
): Promise<{ prefix: string; notes: string[]; chars: number }> {
|
||||
const instructionsFilePath = asString(config.instructionsFilePath, "").trim();
|
||||
if (!instructionsFilePath) {
|
||||
return { prefix: "", notes: [], chars: 0 };
|
||||
}
|
||||
|
||||
try {
|
||||
const contents = await fs.readFile(instructionsFilePath, "utf8");
|
||||
const instructionsDir = `${path.dirname(instructionsFilePath)}/`;
|
||||
const prefix = `${contents.trim()}\n\nThe above agent instructions were loaded from ${instructionsFilePath}. Resolve any relative file references from ${instructionsDir}.\n`;
|
||||
return {
|
||||
prefix,
|
||||
chars: prefix.length,
|
||||
notes: [
|
||||
`Loaded agent instructions from ${instructionsFilePath}`,
|
||||
`Prepended instructions + path directive to prompt (relative references from ${instructionsDir}).`,
|
||||
],
|
||||
};
|
||||
} catch (err) {
|
||||
const reason = err instanceof Error ? err.message : String(err);
|
||||
await onLog(
|
||||
"stderr",
|
||||
`[paperclip] Warning: could not read agent instructions file "${instructionsFilePath}": ${reason}\n`,
|
||||
);
|
||||
return {
|
||||
prefix: "",
|
||||
chars: 0,
|
||||
notes: [
|
||||
`Configured instructionsFilePath ${instructionsFilePath}, but file could not be read; continuing without injected instructions.`,
|
||||
],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function renderPaperclipEnvNote(env: Record<string, string>): string {
|
||||
const keys = Object.keys(env)
|
||||
.filter((key) => key.startsWith("PAPERCLIP_"))
|
||||
.sort();
|
||||
if (keys.length === 0) return "";
|
||||
return [
|
||||
"Paperclip runtime note:",
|
||||
`The following PAPERCLIP_* environment variables are available in the cloud agent shell: ${keys.join(", ")}`,
|
||||
"Use them directly instead of assuming they are absent.",
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
function readSession(params: Record<string, unknown> | null): CursorCloudSession | null {
|
||||
if (!params) return null;
|
||||
const record = asRecord(params);
|
||||
if (!record) return null;
|
||||
const cursorAgentId =
|
||||
trimNullable(record.cursorAgentId) ??
|
||||
trimNullable(record.agentId) ??
|
||||
trimNullable(record.sessionId);
|
||||
if (!cursorAgentId) return null;
|
||||
const latestRunId = trimNullable(record.latestRunId) ?? trimNullable(record.runId) ?? undefined;
|
||||
const envType = trimNullable(record.envType);
|
||||
const envName = trimNullable(record.envName);
|
||||
const reposValue = Array.isArray(record.repos) ? record.repos : [];
|
||||
const repos = reposValue
|
||||
.map((entry) => asRecord(entry))
|
||||
.filter((entry): entry is Record<string, unknown> => Boolean(entry))
|
||||
.map((entry) => ({
|
||||
url: asString(entry.url, "").trim(),
|
||||
startingRef: trimNullable(entry.startingRef) ?? undefined,
|
||||
prUrl: trimNullable(entry.prUrl) ?? undefined,
|
||||
}))
|
||||
.filter((entry) => entry.url.length > 0);
|
||||
return {
|
||||
cursorAgentId,
|
||||
...(latestRunId ? { latestRunId } : {}),
|
||||
runtime: "cloud",
|
||||
...(envType ? { envType: normalizeEnvType(envType) } : {}),
|
||||
...(envName ? { envName } : {}),
|
||||
repos,
|
||||
};
|
||||
}
|
||||
|
||||
function sessionMatches(
|
||||
session: CursorCloudSession | null,
|
||||
envType: "cloud" | "pool" | "machine",
|
||||
envName: string | null,
|
||||
repos: Array<{ url: string; startingRef?: string; prUrl?: string }>,
|
||||
): boolean {
|
||||
if (!session) return false;
|
||||
if ((session.envType ?? "cloud") !== envType) return false;
|
||||
if ((session.envName ?? null) !== envName) return false;
|
||||
if (session.repos.length !== repos.length) return false;
|
||||
return session.repos.every((repo, index) => {
|
||||
const next = repos[index];
|
||||
return repo.url === next.url
|
||||
&& (repo.startingRef ?? null) === (next.startingRef ?? null)
|
||||
&& (repo.prUrl ?? null) === (next.prUrl ?? null);
|
||||
});
|
||||
}
|
||||
|
||||
function buildAgentOptions(input: {
|
||||
apiKey: string;
|
||||
name: string;
|
||||
model?: ModelSelection;
|
||||
envType: "cloud" | "pool" | "machine";
|
||||
envName: string | null;
|
||||
repos: Array<{ url: string; startingRef?: string; prUrl?: string }>;
|
||||
workOnCurrentBranch: boolean;
|
||||
autoCreatePR: boolean;
|
||||
skipReviewerRequest: boolean;
|
||||
envVars: Record<string, string>;
|
||||
}): AgentOptions {
|
||||
return {
|
||||
apiKey: input.apiKey,
|
||||
name: input.name,
|
||||
...(input.model ? { model: input.model } : {}),
|
||||
cloud: {
|
||||
env: {
|
||||
type: input.envType,
|
||||
...(input.envName ? { name: input.envName } : {}),
|
||||
},
|
||||
repos: input.repos,
|
||||
workOnCurrentBranch: input.workOnCurrentBranch,
|
||||
autoCreatePR: input.autoCreatePR,
|
||||
skipReviewerRequest: input.skipReviewerRequest,
|
||||
envVars: input.envVars,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function eventLine(event: CursorCloudEvent): string {
|
||||
return `${JSON.stringify(event)}\n`;
|
||||
}
|
||||
|
||||
async function emitMessage(onLog: AdapterExecutionContext["onLog"], message: SDKMessage) {
|
||||
await onLog("stdout", eventLine({ type: "cursor_cloud.message", message }));
|
||||
}
|
||||
|
||||
async function emitStatus(onLog: AdapterExecutionContext["onLog"], status: string, message?: string) {
|
||||
await onLog("stdout", eventLine({ type: "cursor_cloud.status", status, ...(message ? { message } : {}) }));
|
||||
}
|
||||
|
||||
async function streamRun(run: Run, onLog: AdapterExecutionContext["onLog"]) {
|
||||
if (!run.supports("stream")) return;
|
||||
for await (const message of run.stream()) {
|
||||
await emitMessage(onLog, message);
|
||||
}
|
||||
}
|
||||
|
||||
async function getAttachedRun(input: {
|
||||
apiKey: string;
|
||||
session: CursorCloudSession | null;
|
||||
}): Promise<Run | null> {
|
||||
const latestRunId = input.session?.latestRunId;
|
||||
const cursorAgentId = input.session?.cursorAgentId;
|
||||
if (!latestRunId || !cursorAgentId) return null;
|
||||
try {
|
||||
const run = await Agent.getRun(latestRunId, {
|
||||
runtime: "cloud",
|
||||
agentId: cursorAgentId,
|
||||
apiKey: input.apiKey,
|
||||
});
|
||||
return run.status === "running" ? run : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExecutionResult> {
|
||||
const { runId, agent, runtime, config, context, onLog, onMeta } = ctx;
|
||||
const envConfig = asStringEnvMap(config.env);
|
||||
const apiKey = asString(envConfig.CURSOR_API_KEY, "").trim();
|
||||
if (!apiKey) {
|
||||
return {
|
||||
exitCode: 1,
|
||||
signal: null,
|
||||
timedOut: false,
|
||||
errorMessage: "CURSOR_API_KEY is required for cursor_cloud.",
|
||||
provider: "cursor",
|
||||
biller: "cursor",
|
||||
billingType: "api",
|
||||
clearSession: false,
|
||||
};
|
||||
}
|
||||
|
||||
const workspace = parseObject(context.paperclipWorkspace);
|
||||
const repoUrl =
|
||||
asString(config.repoUrl, "").trim() ||
|
||||
asString(workspace.repoUrl, "").trim();
|
||||
if (!repoUrl) {
|
||||
return {
|
||||
exitCode: 1,
|
||||
signal: null,
|
||||
timedOut: false,
|
||||
errorMessage: "cursor_cloud requires repoUrl in adapterConfig or workspace context.",
|
||||
provider: "cursor",
|
||||
biller: "cursor",
|
||||
billingType: "api",
|
||||
clearSession: false,
|
||||
};
|
||||
}
|
||||
|
||||
const repoStartingRef =
|
||||
trimNullable(config.repoStartingRef) ??
|
||||
trimNullable(workspace.repoRef) ??
|
||||
undefined;
|
||||
const repoPullRequestUrl = trimNullable(config.repoPullRequestUrl) ?? undefined;
|
||||
const envType = normalizeEnvType(asString(config.runtimeEnvType, "cloud"));
|
||||
const envName = trimNullable(config.runtimeEnvName);
|
||||
const workOnCurrentBranch = asBoolean(config.workOnCurrentBranch, false);
|
||||
const autoCreatePR = asBoolean(config.autoCreatePR, false);
|
||||
const skipReviewerRequest = asBoolean(config.skipReviewerRequest, false);
|
||||
const model = toModelSelection(asString(config.model, ""));
|
||||
const repos = [{
|
||||
url: repoUrl,
|
||||
...(repoStartingRef ? { startingRef: repoStartingRef } : {}),
|
||||
...(repoPullRequestUrl ? { prUrl: repoPullRequestUrl } : {}),
|
||||
}];
|
||||
const remoteEnv = buildWakeEnv(ctx, envConfig);
|
||||
const session = readSession(runtime.sessionParams) ?? (runtime.sessionId
|
||||
? {
|
||||
cursorAgentId: runtime.sessionId,
|
||||
runtime: "cloud" as const,
|
||||
repos,
|
||||
}
|
||||
: null);
|
||||
const canReuseSession = sessionMatches(session, envType, envName, repos);
|
||||
const promptTemplate = asString(config.promptTemplate, DEFAULT_PAPERCLIP_AGENT_PROMPT_TEMPLATE);
|
||||
const bootstrapPromptTemplate = asString(config.bootstrapPromptTemplate, "");
|
||||
const templateData = {
|
||||
agentId: agent.id,
|
||||
companyId: agent.companyId,
|
||||
runId,
|
||||
company: { id: agent.companyId },
|
||||
agent,
|
||||
run: { id: runId, source: "on_demand" },
|
||||
context,
|
||||
};
|
||||
const instructions = await buildInstructionsPrefix(config, onLog);
|
||||
const wakePrompt = renderPaperclipWakePrompt(context.paperclipWake, { resumedSession: canReuseSession });
|
||||
const renderedBootstrapPrompt =
|
||||
!canReuseSession && bootstrapPromptTemplate.trim().length > 0
|
||||
? renderTemplate(bootstrapPromptTemplate, templateData).trim()
|
||||
: "";
|
||||
const renderedPrompt =
|
||||
canReuseSession && wakePrompt.length > 0
|
||||
? ""
|
||||
: renderTemplate(promptTemplate, templateData).trim();
|
||||
const paperclipEnvNote = renderPaperclipEnvNote(remoteEnv);
|
||||
const prompt = joinPromptSections([
|
||||
instructions.prefix,
|
||||
renderedBootstrapPrompt,
|
||||
wakePrompt,
|
||||
paperclipEnvNote,
|
||||
renderedPrompt,
|
||||
]);
|
||||
const sessionHandoffNote = asString(context.paperclipSessionHandoffMarkdown, "").trim();
|
||||
const finalPrompt = joinPromptSections([prompt, sessionHandoffNote]);
|
||||
|
||||
const agentOptions = buildAgentOptions({
|
||||
apiKey,
|
||||
name: `Paperclip ${agent.name}`,
|
||||
model,
|
||||
envType,
|
||||
envName,
|
||||
repos,
|
||||
workOnCurrentBranch,
|
||||
autoCreatePR,
|
||||
skipReviewerRequest,
|
||||
envVars: remoteEnv,
|
||||
});
|
||||
|
||||
const commandNotes = [
|
||||
...instructions.notes,
|
||||
canReuseSession
|
||||
? `Reusing Cursor cloud agent session ${session?.cursorAgentId ?? "unknown"}`
|
||||
: "Creating a new Cursor cloud agent session",
|
||||
`Repository: ${repoUrl}${repoStartingRef ? ` @ ${repoStartingRef}` : ""}`,
|
||||
`Runtime target: ${envType}${envName ? ` (${envName})` : ""}`,
|
||||
];
|
||||
|
||||
if (onMeta) {
|
||||
const meta: AdapterInvocationMeta = {
|
||||
adapterType: "cursor_cloud",
|
||||
command: "@cursor/sdk",
|
||||
commandNotes,
|
||||
prompt: finalPrompt,
|
||||
promptMetrics: {
|
||||
promptChars: finalPrompt.length,
|
||||
instructionsChars: instructions.chars,
|
||||
bootstrapPromptChars: renderedBootstrapPrompt.length,
|
||||
wakePromptChars: wakePrompt.length,
|
||||
heartbeatPromptChars: renderedPrompt.length,
|
||||
},
|
||||
context: {
|
||||
cursorCloud: {
|
||||
envType,
|
||||
envName,
|
||||
repoUrl,
|
||||
repoStartingRef,
|
||||
repoPullRequestUrl,
|
||||
canReuseSession,
|
||||
},
|
||||
},
|
||||
};
|
||||
await onMeta(meta);
|
||||
}
|
||||
|
||||
let sdkAgent: SDKAgent | null = null;
|
||||
let run: Run | null = null;
|
||||
let streamError: string | null = null;
|
||||
try {
|
||||
const attachedRun = canReuseSession
|
||||
? await getAttachedRun({ apiKey, session })
|
||||
: null;
|
||||
|
||||
if (attachedRun) {
|
||||
await emitStatus(onLog, "running", `Reattached to existing Cursor run ${attachedRun.id}.`);
|
||||
await onLog("stdout", eventLine({
|
||||
type: "cursor_cloud.init",
|
||||
sessionId: attachedRun.agentId,
|
||||
agentId: attachedRun.agentId,
|
||||
runId: attachedRun.id,
|
||||
...(model?.id ? { model: model.id } : {}),
|
||||
}));
|
||||
const priorStreamPromise = streamRun(attachedRun, onLog).catch((err) => {
|
||||
streamError = formatRunError(err);
|
||||
});
|
||||
if (attachedRun.supports("wait")) await attachedRun.wait();
|
||||
await priorStreamPromise;
|
||||
streamError = null;
|
||||
await emitStatus(
|
||||
onLog,
|
||||
"running",
|
||||
`Prior Cursor run ${attachedRun.id} finished; sending heartbeat follow-up so this wake's context is not dropped.`,
|
||||
);
|
||||
}
|
||||
|
||||
sdkAgent = canReuseSession && session
|
||||
? await Agent.resume(session.cursorAgentId, agentOptions)
|
||||
: await Agent.create(agentOptions);
|
||||
run = await sdkAgent.send(finalPrompt, {
|
||||
...(model ? { model } : {}),
|
||||
});
|
||||
await onLog("stdout", eventLine({
|
||||
type: "cursor_cloud.init",
|
||||
sessionId: sdkAgent.agentId,
|
||||
agentId: sdkAgent.agentId,
|
||||
runId: run.id,
|
||||
...(model?.id ? { model: model.id } : {}),
|
||||
}));
|
||||
await emitStatus(onLog, "running", `Started Cursor run ${run.id}.`);
|
||||
|
||||
const streamPromise = streamRun(run, onLog).catch((err) => {
|
||||
streamError = formatRunError(err);
|
||||
});
|
||||
const result = run.supports("wait")
|
||||
? await run.wait()
|
||||
: {
|
||||
id: run.id,
|
||||
status: run.status === "running" ? "error" : run.status,
|
||||
result: run.result,
|
||||
model: run.model,
|
||||
durationMs: run.durationMs,
|
||||
git: run.git,
|
||||
};
|
||||
await streamPromise;
|
||||
|
||||
const modelId = result.model?.id ?? model?.id ?? null;
|
||||
await onLog("stdout", eventLine({
|
||||
type: "cursor_cloud.result",
|
||||
status: result.status,
|
||||
...(result.result ? { result: result.result } : {}),
|
||||
...(modelId ? { model: modelId } : {}),
|
||||
...(typeof result.durationMs === "number" ? { durationMs: result.durationMs } : {}),
|
||||
...(result.git ? { git: result.git } : {}),
|
||||
...(streamError ? { error: streamError } : {}),
|
||||
}));
|
||||
|
||||
const nextSession: CursorCloudSession = {
|
||||
cursorAgentId: run.agentId,
|
||||
latestRunId: result.id,
|
||||
runtime: "cloud",
|
||||
envType,
|
||||
...(envName ? { envName } : {}),
|
||||
repos,
|
||||
};
|
||||
const isError = result.status !== "finished";
|
||||
return {
|
||||
exitCode: isError ? 1 : 0,
|
||||
signal: null,
|
||||
timedOut: false,
|
||||
errorMessage: isError ? (trimNullable(result.result) ?? streamError ?? `Cursor run ${result.status}`) : null,
|
||||
sessionId: run.agentId,
|
||||
sessionDisplayId: run.agentId,
|
||||
sessionParams: nextSession,
|
||||
provider: "cursor",
|
||||
biller: "cursor",
|
||||
billingType: "api",
|
||||
model: modelId,
|
||||
costUsd: null,
|
||||
summary: toSummary(result),
|
||||
resultJson: {
|
||||
status: result.status,
|
||||
cursorAgentId: run.agentId,
|
||||
cursorRunId: result.id,
|
||||
envType,
|
||||
envName,
|
||||
repos,
|
||||
...(result.result ? { result: result.result } : {}),
|
||||
...(result.git ? { git: result.git } : {}),
|
||||
...(typeof result.durationMs === "number" ? { durationMs: result.durationMs } : {}),
|
||||
...(streamError ? { streamError } : {}),
|
||||
},
|
||||
clearSession: false,
|
||||
};
|
||||
} catch (err) {
|
||||
const reason = formatRunError(err);
|
||||
if (run) {
|
||||
await onLog("stdout", eventLine({
|
||||
type: "cursor_cloud.result",
|
||||
status: "error",
|
||||
error: reason,
|
||||
}));
|
||||
}
|
||||
return {
|
||||
exitCode: 1,
|
||||
signal: null,
|
||||
timedOut: false,
|
||||
errorMessage: reason,
|
||||
sessionId: session?.cursorAgentId ?? null,
|
||||
sessionDisplayId: session?.cursorAgentId ?? null,
|
||||
sessionParams: session,
|
||||
provider: "cursor",
|
||||
biller: "cursor",
|
||||
billingType: "api",
|
||||
costUsd: null,
|
||||
clearSession: false,
|
||||
resultJson: {
|
||||
status: "error",
|
||||
...(run ? { cursorRunId: run.id } : {}),
|
||||
...(session?.cursorAgentId ? { cursorAgentId: session.cursorAgentId } : {}),
|
||||
error: reason,
|
||||
},
|
||||
};
|
||||
} finally {
|
||||
if (sdkAgent) {
|
||||
try {
|
||||
await sdkAgent[Symbol.asyncDispose]();
|
||||
} catch {
|
||||
// Best effort only.
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
export { execute } from "./execute.js";
|
||||
export { testEnvironment } from "./test.js";
|
||||
export { sessionCodec } from "./session.js";
|
||||
|
||||
import type { AdapterConfigSchema } from "@paperclipai/adapter-utils";
|
||||
|
||||
export function getConfigSchema(): AdapterConfigSchema {
|
||||
return {
|
||||
fields: [
|
||||
{
|
||||
key: "repoUrl",
|
||||
label: "Repository URL",
|
||||
type: "text",
|
||||
required: true,
|
||||
hint: "Git repository URL Cursor should open for this agent.",
|
||||
},
|
||||
{
|
||||
key: "repoStartingRef",
|
||||
label: "Starting ref",
|
||||
type: "text",
|
||||
hint: "Optional branch, tag, or SHA Cursor should start from.",
|
||||
},
|
||||
{
|
||||
key: "repoPullRequestUrl",
|
||||
label: "Pull request URL",
|
||||
type: "text",
|
||||
hint: "Optional PR URL when attaching the agent to an existing review branch.",
|
||||
},
|
||||
{
|
||||
key: "runtimeEnvType",
|
||||
label: "Cursor runtime",
|
||||
type: "select",
|
||||
default: "cloud",
|
||||
options: [
|
||||
{ value: "cloud", label: "Cursor hosted" },
|
||||
{ value: "pool", label: "Self-hosted pool" },
|
||||
{ value: "machine", label: "Named machine" },
|
||||
],
|
||||
hint: "Choose where Cursor should execute the remote agent.",
|
||||
},
|
||||
{
|
||||
key: "runtimeEnvName",
|
||||
label: "Runtime name",
|
||||
type: "text",
|
||||
hint: "Optional pool or machine name when targeting a non-default runtime.",
|
||||
},
|
||||
{
|
||||
key: "workOnCurrentBranch",
|
||||
label: "Work on current branch",
|
||||
type: "toggle",
|
||||
default: false,
|
||||
hint: "Tell Cursor to continue on the current branch instead of making a new one.",
|
||||
},
|
||||
{
|
||||
key: "autoCreatePR",
|
||||
label: "Auto-create PR",
|
||||
type: "toggle",
|
||||
default: false,
|
||||
hint: "Allow Cursor to automatically create a pull request for the work.",
|
||||
},
|
||||
{
|
||||
key: "skipReviewerRequest",
|
||||
label: "Skip reviewer request",
|
||||
type: "toggle",
|
||||
default: false,
|
||||
hint: "Suppress reviewer requests on auto-created pull requests.",
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { sessionCodec } from "./session.js";
|
||||
|
||||
describe("cursorCloud sessionCodec", () => {
|
||||
it("normalizes legacy and current session identifiers", () => {
|
||||
expect(
|
||||
sessionCodec.deserialize({
|
||||
agentId: "agent-123",
|
||||
runId: "run-456",
|
||||
envType: "pool",
|
||||
envName: "trusted",
|
||||
repos: [{ url: "https://github.com/paperclipai/paperclip.git", startingRef: "main" }],
|
||||
}),
|
||||
).toEqual({
|
||||
cursorAgentId: "agent-123",
|
||||
latestRunId: "run-456",
|
||||
runtime: "cloud",
|
||||
envType: "pool",
|
||||
envName: "trusted",
|
||||
repos: [{ url: "https://github.com/paperclipai/paperclip.git", startingRef: "main" }],
|
||||
});
|
||||
});
|
||||
|
||||
it("drops invalid session payloads and exposes the display id", () => {
|
||||
expect(sessionCodec.deserialize({ latestRunId: "run-1" })).toBeNull();
|
||||
expect(sessionCodec.getDisplayId?.({
|
||||
cursorAgentId: "agent-789",
|
||||
latestRunId: "run-101",
|
||||
})).toBe("agent-789");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,61 @@
|
||||
import type { AdapterSessionCodec } from "@paperclipai/adapter-utils";
|
||||
|
||||
function asRecord(value: unknown): Record<string, unknown> | null {
|
||||
if (typeof value !== "object" || value === null || Array.isArray(value)) return null;
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function readString(value: unknown): string | null {
|
||||
return typeof value === "string" && value.trim().length > 0 ? value.trim() : null;
|
||||
}
|
||||
|
||||
function readRepos(value: unknown): Array<{ url: string; startingRef?: string; prUrl?: string }> {
|
||||
if (!Array.isArray(value)) return [];
|
||||
const repos: Array<{ url: string; startingRef?: string; prUrl?: string }> = [];
|
||||
for (const entry of value) {
|
||||
const repo = asRecord(entry);
|
||||
if (!repo) continue;
|
||||
const url = readString(repo.url);
|
||||
if (!url) continue;
|
||||
const startingRef = readString(repo.startingRef);
|
||||
const prUrl = readString(repo.prUrl);
|
||||
repos.push({
|
||||
url,
|
||||
...(startingRef ? { startingRef } : {}),
|
||||
...(prUrl ? { prUrl } : {}),
|
||||
});
|
||||
}
|
||||
return repos;
|
||||
}
|
||||
|
||||
function normalize(raw: unknown): Record<string, unknown> | null {
|
||||
const record = asRecord(raw);
|
||||
if (!record) return null;
|
||||
const cursorAgentId =
|
||||
readString(record.cursorAgentId) ??
|
||||
readString(record.agentId) ??
|
||||
readString(record.sessionId);
|
||||
if (!cursorAgentId) return null;
|
||||
const latestRunId = readString(record.latestRunId) ?? readString(record.runId);
|
||||
const runtime = readString(record.runtime) ?? "cloud";
|
||||
const envType = readString(record.envType);
|
||||
const envName = readString(record.envName);
|
||||
const repos = readRepos(record.repos);
|
||||
return {
|
||||
cursorAgentId,
|
||||
...(latestRunId ? { latestRunId } : {}),
|
||||
runtime,
|
||||
...(envType ? { envType } : {}),
|
||||
...(envName ? { envName } : {}),
|
||||
...(repos.length > 0 ? { repos } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
export const sessionCodec: AdapterSessionCodec = {
|
||||
deserialize: normalize,
|
||||
serialize: normalize,
|
||||
getDisplayId(params) {
|
||||
const normalized = normalize(params);
|
||||
return normalized ? String(normalized.cursorAgentId) : null;
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,118 @@
|
||||
import { Cursor } from "@cursor/sdk";
|
||||
import type {
|
||||
AdapterEnvironmentCheck,
|
||||
AdapterEnvironmentTestContext,
|
||||
AdapterEnvironmentTestResult,
|
||||
} from "@paperclipai/adapter-utils";
|
||||
import { asString, parseObject } from "@paperclipai/adapter-utils/server-utils";
|
||||
|
||||
function summarizeStatus(checks: AdapterEnvironmentCheck[]): AdapterEnvironmentTestResult["status"] {
|
||||
if (checks.some((check) => check.level === "error")) return "fail";
|
||||
if (checks.some((check) => check.level === "warn")) return "warn";
|
||||
return "pass";
|
||||
}
|
||||
|
||||
function asStringEnvMap(value: unknown): Record<string, string> {
|
||||
const parsed = parseObject(value);
|
||||
const env: Record<string, string> = {};
|
||||
for (const [key, entry] of Object.entries(parsed)) {
|
||||
if (typeof entry === "string") {
|
||||
env[key] = entry;
|
||||
} else if (typeof entry === "object" && entry !== null && !Array.isArray(entry)) {
|
||||
const rec = entry as Record<string, unknown>;
|
||||
if (rec.type === "plain" && typeof rec.value === "string") env[key] = rec.value;
|
||||
}
|
||||
}
|
||||
return env;
|
||||
}
|
||||
|
||||
function looksLikeRepoUrl(value: string): boolean {
|
||||
return /^(https?:\/\/|git@)/i.test(value.trim());
|
||||
}
|
||||
|
||||
export async function testEnvironment(
|
||||
ctx: AdapterEnvironmentTestContext,
|
||||
): Promise<AdapterEnvironmentTestResult> {
|
||||
const checks: AdapterEnvironmentCheck[] = [];
|
||||
const config = parseObject(ctx.config);
|
||||
const env = asStringEnvMap(config.env);
|
||||
const apiKey = asString(env.CURSOR_API_KEY, "").trim();
|
||||
const repoUrl = asString(config.repoUrl, "").trim();
|
||||
const model = asString(config.model, "").trim();
|
||||
|
||||
if (!apiKey) {
|
||||
checks.push({
|
||||
code: "cursor_cloud_api_key_missing",
|
||||
level: "error",
|
||||
message: "CURSOR_API_KEY is required.",
|
||||
hint: "Add CURSOR_API_KEY under environment variables for this adapter.",
|
||||
});
|
||||
}
|
||||
|
||||
if (!repoUrl) {
|
||||
checks.push({
|
||||
code: "cursor_cloud_repo_missing",
|
||||
level: "error",
|
||||
message: "repoUrl is required.",
|
||||
hint: "Set the repository URL Cursor should open for this agent.",
|
||||
});
|
||||
} else if (!looksLikeRepoUrl(repoUrl)) {
|
||||
checks.push({
|
||||
code: "cursor_cloud_repo_invalid",
|
||||
level: "error",
|
||||
message: "repoUrl must be an http(s) or git SSH repository URL.",
|
||||
detail: repoUrl,
|
||||
});
|
||||
} else {
|
||||
checks.push({
|
||||
code: "cursor_cloud_repo_present",
|
||||
level: "info",
|
||||
message: `Repository configured: ${repoUrl}`,
|
||||
});
|
||||
}
|
||||
|
||||
if (apiKey) {
|
||||
try {
|
||||
const me = await Cursor.me({ apiKey });
|
||||
checks.push({
|
||||
code: "cursor_cloud_auth_ok",
|
||||
level: "info",
|
||||
message: "Cursor API key is valid.",
|
||||
detail: me.userEmail ? `Authenticated as ${me.userEmail}.` : `API key: ${me.apiKeyName}`,
|
||||
});
|
||||
} catch (err) {
|
||||
checks.push({
|
||||
code: "cursor_cloud_auth_failed",
|
||||
level: "error",
|
||||
message: err instanceof Error ? err.message : "Failed to validate Cursor API key.",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (apiKey && model) {
|
||||
try {
|
||||
const models = await Cursor.models.list({ apiKey });
|
||||
const match = models.find((entry) => entry.id === model);
|
||||
checks.push({
|
||||
code: match ? "cursor_cloud_model_ok" : "cursor_cloud_model_unknown",
|
||||
level: match ? "info" : "warn",
|
||||
message: match
|
||||
? `Model "${model}" is available to the authenticated Cursor account.`
|
||||
: `Model "${model}" was not found in the authenticated Cursor model list.`,
|
||||
});
|
||||
} catch (err) {
|
||||
checks.push({
|
||||
code: "cursor_cloud_model_probe_failed",
|
||||
level: "warn",
|
||||
message: err instanceof Error ? err.message : "Failed to validate model availability.",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
adapterType: ctx.adapterType,
|
||||
status: summarizeStatus(checks),
|
||||
checks,
|
||||
testedAt: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { CreateConfigValues } from "@paperclipai/adapter-utils";
|
||||
import { buildCursorCloudConfig } from "./build-config.js";
|
||||
|
||||
function makeValues(overrides: Partial<CreateConfigValues> = {}): CreateConfigValues {
|
||||
return {
|
||||
adapterType: "cursor_cloud",
|
||||
cwd: "",
|
||||
instructionsFilePath: "",
|
||||
promptTemplate: "",
|
||||
model: "",
|
||||
thinkingEffort: "",
|
||||
chrome: false,
|
||||
dangerouslySkipPermissions: false,
|
||||
search: false,
|
||||
fastMode: false,
|
||||
dangerouslyBypassSandbox: false,
|
||||
command: "",
|
||||
args: "",
|
||||
extraArgs: "",
|
||||
envVars: "",
|
||||
envBindings: {},
|
||||
url: "",
|
||||
bootstrapPrompt: "",
|
||||
payloadTemplateJson: "",
|
||||
workspaceStrategyType: "project_primary",
|
||||
workspaceBaseRef: "",
|
||||
workspaceBranchTemplate: "",
|
||||
worktreeParentDir: "",
|
||||
runtimeServicesJson: "",
|
||||
maxTurnsPerRun: 1000,
|
||||
heartbeatEnabled: false,
|
||||
intervalSec: 300,
|
||||
adapterSchemaValues: {},
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("buildCursorCloudConfig", () => {
|
||||
it("persists schema values and top-level prompt fields", () => {
|
||||
const config = buildCursorCloudConfig(
|
||||
makeValues({
|
||||
instructionsFilePath: ".cursor/AGENTS.md",
|
||||
promptTemplate: "hello {{agent.name}}",
|
||||
bootstrapPrompt: "bootstrap",
|
||||
model: "gpt-5.4",
|
||||
adapterSchemaValues: {
|
||||
repoUrl: "https://github.com/paperclipai/paperclip.git",
|
||||
runtimeEnvType: "pool",
|
||||
runtimeEnvName: "trusted-workers",
|
||||
autoCreatePR: true,
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
expect(config).toMatchObject({
|
||||
instructionsFilePath: ".cursor/AGENTS.md",
|
||||
promptTemplate: "hello {{agent.name}}",
|
||||
bootstrapPromptTemplate: "bootstrap",
|
||||
model: "gpt-5.4",
|
||||
repoUrl: "https://github.com/paperclipai/paperclip.git",
|
||||
runtimeEnvType: "pool",
|
||||
runtimeEnvName: "trusted-workers",
|
||||
autoCreatePR: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("merges structured env bindings over legacy envVars text", () => {
|
||||
const config = buildCursorCloudConfig(
|
||||
makeValues({
|
||||
envVars: ["CURSOR_API_KEY=legacy-key", "PLAIN=value", "INVALID KEY=nope"].join("\n"),
|
||||
envBindings: {
|
||||
CURSOR_API_KEY: { type: "secret_ref", secretId: "secret-1", version: "latest" },
|
||||
STRUCTURED_ONLY: "from-binding",
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
expect(config.env).toEqual({
|
||||
CURSOR_API_KEY: { type: "secret_ref", secretId: "secret-1", version: "latest" },
|
||||
PLAIN: { type: "plain", value: "value" },
|
||||
STRUCTURED_ONLY: { type: "plain", value: "from-binding" },
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,67 @@
|
||||
import type { CreateConfigValues } from "@paperclipai/adapter-utils";
|
||||
|
||||
function parseEnvVars(text: string): Record<string, string> {
|
||||
const env: Record<string, string> = {};
|
||||
for (const line of text.split(/\r?\n/)) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed || trimmed.startsWith("#")) continue;
|
||||
const eq = trimmed.indexOf("=");
|
||||
if (eq <= 0) continue;
|
||||
const key = trimmed.slice(0, eq).trim();
|
||||
const value = trimmed.slice(eq + 1);
|
||||
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key)) continue;
|
||||
env[key] = value;
|
||||
}
|
||||
return env;
|
||||
}
|
||||
|
||||
function parseEnvBindings(bindings: unknown): Record<string, unknown> {
|
||||
if (typeof bindings !== "object" || bindings === null || Array.isArray(bindings)) return {};
|
||||
const env: Record<string, unknown> = {};
|
||||
for (const [key, raw] of Object.entries(bindings)) {
|
||||
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key)) continue;
|
||||
if (typeof raw === "string") {
|
||||
env[key] = { type: "plain", value: raw };
|
||||
continue;
|
||||
}
|
||||
if (typeof raw !== "object" || raw === null || Array.isArray(raw)) continue;
|
||||
const rec = raw as Record<string, unknown>;
|
||||
if (rec.type === "plain" && typeof rec.value === "string") {
|
||||
env[key] = { type: "plain", value: rec.value };
|
||||
continue;
|
||||
}
|
||||
if (rec.type === "secret_ref" && typeof rec.secretId === "string") {
|
||||
env[key] = {
|
||||
type: "secret_ref",
|
||||
secretId: rec.secretId,
|
||||
...(typeof rec.version === "number" || rec.version === "latest"
|
||||
? { version: rec.version }
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
}
|
||||
return env;
|
||||
}
|
||||
|
||||
export function buildCursorCloudConfig(values: CreateConfigValues): Record<string, unknown> {
|
||||
const config: Record<string, unknown> = {
|
||||
...(values.adapterSchemaValues ?? {}),
|
||||
};
|
||||
if (values.instructionsFilePath) config.instructionsFilePath = values.instructionsFilePath;
|
||||
if (values.promptTemplate) config.promptTemplate = values.promptTemplate;
|
||||
if (values.bootstrapPrompt) config.bootstrapPromptTemplate = values.bootstrapPrompt;
|
||||
if (values.model?.trim()) config.model = values.model.trim();
|
||||
|
||||
const env = parseEnvBindings(values.envBindings);
|
||||
const legacy = parseEnvVars(values.envVars);
|
||||
for (const [key, value] of Object.entries(legacy)) {
|
||||
if (!Object.prototype.hasOwnProperty.call(env, key)) {
|
||||
env[key] = { type: "plain", value };
|
||||
}
|
||||
}
|
||||
if (Object.keys(env).length > 0) {
|
||||
config.env = env;
|
||||
}
|
||||
|
||||
return config;
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export { buildCursorCloudConfig } from "./build-config.js";
|
||||
export { parseCursorCloudStdoutLine } from "./parse-stdout.js";
|
||||
@@ -0,0 +1,143 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { parseCursorCloudStdoutLine } from "./parse-stdout.js";
|
||||
|
||||
const ts = "2026-05-10T05:10:00.000Z";
|
||||
|
||||
describe("parseCursorCloudStdoutLine", () => {
|
||||
it("parses init and status events", () => {
|
||||
expect(
|
||||
parseCursorCloudStdoutLine(
|
||||
JSON.stringify({ type: "cursor_cloud.init", sessionId: "agent-123", model: "gpt-5.4" }),
|
||||
ts,
|
||||
),
|
||||
).toEqual([{ kind: "init", ts, sessionId: "agent-123", model: "gpt-5.4" }]);
|
||||
|
||||
expect(
|
||||
parseCursorCloudStdoutLine(
|
||||
JSON.stringify({ type: "cursor_cloud.status", status: "running", message: "Reattached" }),
|
||||
ts,
|
||||
),
|
||||
).toEqual([{ kind: "system", ts, text: "running: Reattached" }]);
|
||||
});
|
||||
|
||||
it("parses assistant text and tool lifecycle SDK messages", () => {
|
||||
const assistantLine = JSON.stringify({
|
||||
type: "cursor_cloud.message",
|
||||
message: {
|
||||
type: "assistant",
|
||||
message: {
|
||||
content: [
|
||||
{ type: "text", text: "Working on it." },
|
||||
{ type: "tool_use", id: "tool-1", name: "read_file", input: { path: "README.md" } },
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(parseCursorCloudStdoutLine(assistantLine, ts)).toEqual([
|
||||
{ kind: "assistant", ts, text: "Working on it." },
|
||||
{ kind: "tool_call", ts, name: "read_file", toolUseId: "tool-1", input: { path: "README.md" } },
|
||||
]);
|
||||
|
||||
const toolStartLine = JSON.stringify({
|
||||
type: "cursor_cloud.message",
|
||||
message: {
|
||||
type: "tool_call",
|
||||
id: "call-1",
|
||||
name: "bash",
|
||||
status: "running",
|
||||
args: { command: "pwd" },
|
||||
},
|
||||
});
|
||||
expect(parseCursorCloudStdoutLine(toolStartLine, ts)).toEqual([
|
||||
{ kind: "tool_call", ts, name: "bash", toolUseId: "call-1", input: { command: "pwd" } },
|
||||
]);
|
||||
|
||||
const toolEndLine = JSON.stringify({
|
||||
type: "cursor_cloud.message",
|
||||
message: {
|
||||
type: "tool_call",
|
||||
id: "call-1",
|
||||
name: "bash",
|
||||
status: "completed",
|
||||
result: { stdout: "/repo" },
|
||||
},
|
||||
});
|
||||
expect(parseCursorCloudStdoutLine(toolEndLine, ts)).toEqual([
|
||||
{
|
||||
kind: "tool_result",
|
||||
ts,
|
||||
toolUseId: "call-1",
|
||||
toolName: "bash",
|
||||
content: JSON.stringify({ stdout: "/repo" }, null, 2),
|
||||
isError: false,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("parses standalone tool_result SDK messages", () => {
|
||||
const line = JSON.stringify({
|
||||
type: "cursor_cloud.message",
|
||||
message: {
|
||||
type: "tool_result",
|
||||
call_id: "call-9",
|
||||
name: "read_file",
|
||||
result: { contents: "file body" },
|
||||
},
|
||||
});
|
||||
expect(parseCursorCloudStdoutLine(line, ts)).toEqual([
|
||||
{
|
||||
kind: "tool_result",
|
||||
ts,
|
||||
toolUseId: "call-9",
|
||||
toolName: "read_file",
|
||||
content: JSON.stringify({ contents: "file body" }, null, 2),
|
||||
isError: false,
|
||||
},
|
||||
]);
|
||||
|
||||
const errorLine = JSON.stringify({
|
||||
type: "cursor_cloud.message",
|
||||
message: {
|
||||
type: "tool_result",
|
||||
call_id: "call-10",
|
||||
name: "bash",
|
||||
is_error: true,
|
||||
content: "exit 1",
|
||||
},
|
||||
});
|
||||
expect(parseCursorCloudStdoutLine(errorLine, ts)).toEqual([
|
||||
{
|
||||
kind: "tool_result",
|
||||
ts,
|
||||
toolUseId: "call-10",
|
||||
toolName: "bash",
|
||||
content: "exit 1",
|
||||
isError: true,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("parses result events and preserves unknown lines as stdout", () => {
|
||||
expect(
|
||||
parseCursorCloudStdoutLine(
|
||||
JSON.stringify({ type: "cursor_cloud.result", status: "finished", result: "Done", model: "gpt-5.4" }),
|
||||
ts,
|
||||
),
|
||||
).toEqual([
|
||||
{
|
||||
kind: "result",
|
||||
ts,
|
||||
text: "Done",
|
||||
inputTokens: 0,
|
||||
outputTokens: 0,
|
||||
cachedTokens: 0,
|
||||
costUsd: 0,
|
||||
subtype: "finished",
|
||||
isError: false,
|
||||
errors: [],
|
||||
},
|
||||
]);
|
||||
|
||||
expect(parseCursorCloudStdoutLine("plain text", ts)).toEqual([{ kind: "stdout", ts, text: "plain text" }]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,186 @@
|
||||
import type { TranscriptEntry } from "@paperclipai/adapter-utils";
|
||||
|
||||
function safeJsonParse(text: string): unknown {
|
||||
try {
|
||||
return JSON.parse(text);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function asRecord(value: unknown): Record<string, unknown> | null {
|
||||
if (typeof value !== "object" || value === null || Array.isArray(value)) return null;
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function asString(value: unknown, fallback = ""): string {
|
||||
return typeof value === "string" ? value : fallback;
|
||||
}
|
||||
|
||||
function stringifyUnknown(value: unknown): string {
|
||||
if (typeof value === "string") return value;
|
||||
if (value === null || value === undefined) return "";
|
||||
try {
|
||||
return JSON.stringify(value, null, 2);
|
||||
} catch {
|
||||
return String(value);
|
||||
}
|
||||
}
|
||||
|
||||
function parseAssistantMessage(message: Record<string, unknown>, ts: string): TranscriptEntry[] {
|
||||
const content = Array.isArray(message.content) ? message.content : [];
|
||||
const entries: TranscriptEntry[] = [];
|
||||
for (const partRaw of content) {
|
||||
const part = asRecord(partRaw);
|
||||
if (!part) continue;
|
||||
const type = asString(part.type).trim();
|
||||
if (type === "text") {
|
||||
const text = asString(part.text).trim();
|
||||
if (text) entries.push({ kind: "assistant", ts, text });
|
||||
continue;
|
||||
}
|
||||
if (type === "tool_use") {
|
||||
entries.push({
|
||||
kind: "tool_call",
|
||||
ts,
|
||||
name: asString(part.name, "tool"),
|
||||
toolUseId: asString(part.id) || undefined,
|
||||
input: part.input ?? {},
|
||||
});
|
||||
}
|
||||
}
|
||||
return entries;
|
||||
}
|
||||
|
||||
function parseSdkMessage(messageRaw: unknown, ts: string): TranscriptEntry[] {
|
||||
const message = asRecord(messageRaw);
|
||||
if (!message) return [];
|
||||
const type = asString(message.type);
|
||||
|
||||
if (type === "assistant") {
|
||||
const body = asRecord(message.message);
|
||||
return body ? parseAssistantMessage(body, ts) : [];
|
||||
}
|
||||
|
||||
if (type === "user") {
|
||||
const body = asRecord(message.message);
|
||||
const content = Array.isArray(body?.content) ? body.content : [];
|
||||
const text = content
|
||||
.map((entry) => asRecord(entry))
|
||||
.filter((entry): entry is Record<string, unknown> => Boolean(entry))
|
||||
.map((entry) => asString(entry.text).trim())
|
||||
.filter(Boolean)
|
||||
.join("\n");
|
||||
return text ? [{ kind: "user", ts, text }] : [];
|
||||
}
|
||||
|
||||
if (type === "thinking") {
|
||||
const text = asString(message.text).trim();
|
||||
return text ? [{ kind: "thinking", ts, text }] : [];
|
||||
}
|
||||
|
||||
if (type === "tool_call") {
|
||||
const toolUseId = asString(message.call_id, asString(message.id, "tool_call"));
|
||||
const status = asString(message.status).toLowerCase();
|
||||
if (status === "running") {
|
||||
return [{
|
||||
kind: "tool_call",
|
||||
ts,
|
||||
name: asString(message.name, "tool"),
|
||||
toolUseId,
|
||||
input: message.args ?? {},
|
||||
}];
|
||||
}
|
||||
if (status === "completed" || status === "error") {
|
||||
return [{
|
||||
kind: "tool_result",
|
||||
ts,
|
||||
toolUseId,
|
||||
toolName: asString(message.name, "tool"),
|
||||
content: stringifyUnknown(message.result ?? message.args ?? {}),
|
||||
isError: status === "error",
|
||||
}];
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
if (type === "tool_result") {
|
||||
const toolUseId = asString(message.call_id, asString(message.id, "tool_result"));
|
||||
const isError =
|
||||
message.is_error === true ||
|
||||
asString(message.status).toLowerCase() === "error";
|
||||
return [{
|
||||
kind: "tool_result",
|
||||
ts,
|
||||
toolUseId,
|
||||
toolName: asString(message.name, "tool"),
|
||||
content: stringifyUnknown(message.result ?? message.content ?? message.output ?? {}),
|
||||
isError,
|
||||
}];
|
||||
}
|
||||
|
||||
if (type === "status") {
|
||||
const status = asString(message.status);
|
||||
const statusMessage = asString(message.message);
|
||||
return [{
|
||||
kind: "system",
|
||||
ts,
|
||||
text: `status: ${status}${statusMessage ? ` - ${statusMessage}` : ""}`,
|
||||
}];
|
||||
}
|
||||
|
||||
if (type === "task") {
|
||||
const text = asString(message.text).trim();
|
||||
return text ? [{ kind: "system", ts, text }] : [];
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
export function parseCursorCloudStdoutLine(line: string, ts: string): TranscriptEntry[] {
|
||||
const parsed = asRecord(safeJsonParse(line));
|
||||
if (!parsed) {
|
||||
return [{ kind: "stdout", ts, text: line }];
|
||||
}
|
||||
|
||||
const type = asString(parsed.type);
|
||||
if (type === "cursor_cloud.init") {
|
||||
const sessionId = asString(parsed.sessionId, asString(parsed.agentId));
|
||||
return [{
|
||||
kind: "init",
|
||||
ts,
|
||||
model: asString(parsed.model, "cursor_cloud"),
|
||||
sessionId,
|
||||
}];
|
||||
}
|
||||
|
||||
if (type === "cursor_cloud.status") {
|
||||
return [{
|
||||
kind: "system",
|
||||
ts,
|
||||
text: `${asString(parsed.status, "status")}${parsed.message ? `: ${asString(parsed.message)}` : ""}`,
|
||||
}];
|
||||
}
|
||||
|
||||
if (type === "cursor_cloud.message") {
|
||||
return parseSdkMessage(parsed.message, ts);
|
||||
}
|
||||
|
||||
if (type === "cursor_cloud.result") {
|
||||
const status = asString(parsed.status, "error");
|
||||
return [{
|
||||
kind: "result",
|
||||
ts,
|
||||
text: asString(parsed.result),
|
||||
inputTokens: 0,
|
||||
outputTokens: 0,
|
||||
cachedTokens: 0,
|
||||
costUsd: 0,
|
||||
subtype: status,
|
||||
isError: status !== "finished",
|
||||
errors: parsed.error ? [asString(parsed.error)] : [],
|
||||
}];
|
||||
}
|
||||
|
||||
return [{ kind: "stdout", ts, text: line }];
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"extends": "../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "dist",
|
||||
"rootDir": "src",
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
@@ -33,6 +33,7 @@ export const AGENT_ADAPTER_TYPES = [
|
||||
"acpx_local",
|
||||
"claude_local",
|
||||
"codex_local",
|
||||
"cursor_cloud",
|
||||
"gemini_local",
|
||||
"opencode_local",
|
||||
"pi_local",
|
||||
|
||||
Reference in New Issue
Block a user