mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-28 12:04:41 +01:00
## 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>
349 lines
10 KiB
TypeScript
349 lines
10 KiB
TypeScript
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",
|
|
},
|
|
});
|
|
});
|
|
});
|