Skip to content
Source
Sign inSign up
Open History to see the last commit

CursorAgentSdk.test.ts

357 lines12.5 KB

1import type { ListRunsOptions } from "@cursor/sdk";
2import * as NodeServices from "@effect/platform-node/NodeServices";
3import { assert, describe, it } from "@effect/vitest";
4import { ProviderSessionId, ThreadId } from "@t3tools/contracts";
5import * as Effect from "effect/Effect";
6import * as FileSystem from "effect/FileSystem";
7import * as Layer from "effect/Layer";
8import * as Exit from "effect/Exit";
9import * as Cause from "effect/Cause";
10import { vi } from "vite-plus/test";
11​
12import * as ProviderEventLoggers from "@t3tools/provider-core/server/ProviderEventLoggers";
13import * as CursorAgentSdk from "./CursorAgentSdk.ts";
14import * as CursorSdk from "./CursorSdk.ts";
15​
16const cursorSdkMock = vi.hoisted(() => {
17 const closeExecutionOrder: Array<string> = [];
18 const runOperations: Array<string> = [];
19 let agentCloseFailure: unknown;
20 // The local store lists runs oldest first, like the real SDK.
21 let storedRuns: Array<{ readonly id: string; readonly status: string }> = [];
22 const runWait = vi.fn(async () => ({
23 id: "run-cursor-agent-sdk-test",
24 requestId: "request-cursor-agent-sdk-test",
25 status: "finished",
26 result: "ok",
27 model: { id: "default" },
28 durationMs: 1,
29 }));
30 const runCancel = vi.fn(async () => {});
31 const runSteer = vi.fn(async () => "complete_delivered" as const);
32 const agentClose = vi.fn(() => {
33 closeExecutionOrder.push("sdk:agent.close");
34 if (agentCloseFailure !== undefined) {
35 throw agentCloseFailure;
36 }
37 });
38 const send = vi.fn(
39 async (
40 _message: unknown,
41 options: { readonly onDelta?: (input: { readonly update: unknown }) => Promise<void> },
42 ) => {
43 runOperations.push("run.start");
44 if (storedRuns.some((run) => run.status === "running")) {
45 throw new Error("Agent agent-cursor-agent-sdk-test already has active run");
46 }
47 await options.onDelta?.({
48 update: {
49 type: "assistant-message-chunk",
50 text: "hello",
51 },
52 });
53 return {
54 id: "run-cursor-agent-sdk-test",
55 agentId: "agent-cursor-agent-sdk-test",
56 wait: runWait,
57 cancel: runCancel,
58 steer: runSteer as typeof runSteer | undefined,
59 };
60 },
61 );
62 const create = vi.fn(async () => ({
63 agentId: "agent-cursor-agent-sdk-test",
64 send,
65 close: agentClose,
66 }));
67 const resume = vi.fn(async () => ({
68 agentId: "agent-cursor-agent-sdk-test",
69 send,
70 close: agentClose,
71 }));
72 const listRuns = vi.fn(async (_agentId: string, options?: ListRunsOptions) => {
73 runOperations.push("agent.listRuns");
74 // Mirrors the SDK's opaque cursor pagination with its default page size of 50.
75 const offset = options?.cursor === undefined ? 0 : Number(options.cursor);
76 const end = offset + (options?.limit ?? 50);
77 return {
78 items: storedRuns.slice(offset, end),
79 ...(end < storedRuns.length ? { nextCursor: String(end) } : {}),
80 };
81 });
82 const cancelRun = vi.fn(async (runId: string) => {
83 runOperations.push("agent.cancelRun");
84 storedRuns = storedRuns.map((run) =>
85 run.id === runId ? { ...run, status: "cancelled" } : run,
86 );
87 });
88​
89 return {
90 agentClose,
91 cancelRun,
92 closeExecutionOrder,
93 create,
94 listRuns,
95 resume,
96 runOperations,
97 runCancel,
98 runSteer,
99 runWait,
100 send,
101 setAgentCloseFailure: (failure: unknown) => {
102 agentCloseFailure = failure;
103 },
104 setStoredRuns: (runs: Array<{ readonly id: string; readonly status: string }>) => {
105 storedRuns = runs;
106 },
107 };
108});
109​
110vi.mock("./sdk.ts", () => ({
111 loadCursorSdk: () => ({
112 Agent: {
113 create: cursorSdkMock.create,
114 resume: cursorSdkMock.resume,
115 listRuns: cursorSdkMock.listRuns,
116 cancelRun: cursorSdkMock.cancelRun,
117 messages: {
118 list: vi.fn(async () => []),
119 },
120 },
121 createAgentPlatform: vi.fn(),
122 }),
123 loadCursorSqlite: () => ({ SqliteLocalAgentStore: { open: vi.fn() } }),
124}));
125​
126const layerTest = CursorAgentSdk.layer.pipe(
127 Layer.provide(
128 Layer.succeed(ProviderEventLoggers.ProviderEventLoggers, {
129 native: {
130 filePath: "cursor-agent-sdk-test.log",
131 write: (event: unknown) =>
132 Effect.sync(() => {
133 if (
134 typeof event === "object" &&
135 event !== null &&
136 Reflect.get(Reflect.get(event, "event"), "payload")?.type === "agent.close"
137 ) {
138 cursorSdkMock.closeExecutionOrder.push("protocol:agent.close");
139 }
140 }),
141 close: () => Effect.void,
142 },
143 canonical: undefined,
144 }),
145 ),
146 Layer.provide(CursorSdk.layer),
147 Layer.provide(NodeServices.layer),
148);
149​
150describe("CursorAgentSdkRunner", () => {
151 it.effect("resumes from the original store after the working directory changes", () =>
152 Effect.gen(function* () {
153 const fileSystem = yield* FileSystem.FileSystem;
154 const workspace = yield* fileSystem.makeTempDirectoryScoped({ prefix: "cursor-resume-" });
155 const loaders = yield* Effect.promise(() =>
156 vi.importActual<typeof import("./sdk.ts")>("./sdk.ts"),
157 );
158 const sdk = loaders.loadCursorSdk();
159 const store = new sdk.JsonlLocalAgentStore(workspace);
160 const storeCwd = `${workspace}/checkout`;
161 const cwd = `${workspace}/worktree`;
162 const platform = yield* Effect.promise(() =>
163 sdk.createAgentPlatform({ localStore: store, workspaceRef: storeCwd }),
164 );
165 const { agent } = yield* Effect.promise(() =>
166 platform.store.createAgent({ agentId: "agent-moved", workspaceRef: storeCwd }),
167 );
168 const runner = yield* CursorAgentSdk.makeCursorAgentSdkRunner(() => undefined).pipe(
169 Effect.provideService(CursorSdk.CursorSdk, {
170 load: loaders.loadCursorSdk,
171 loadSqlite: loaders.loadCursorSqlite,
172 }),
173 );
174 const session = yield* runner.open({
175 operation: "resume",
176 agentId: agent.agentId,
177 storeCwd,
178 options: { apiKey: "synthetic-cursor-key", local: { cwd, store } },
179 threadId: ThreadId.make("moved-workspace"),
180 providerSessionId: ProviderSessionId.make("moved-workspace"),
181 });
182 assert.equal(session.agentId, agent.agentId);
183 assert.deepStrictEqual(yield* session.listMessages, []);
184 assert.strictEqual(yield* session.readContextUsage!, undefined);
185 yield* session.close;
186 }).pipe(Effect.provide(NodeServices.layer)),
187 );
188​
189 it.effect("constructs without loading and reports first-use load failures as runner errors", () =>
190 Effect.gen(function* () {
191 const failure = new Error("missing SDK chunk");
192 const load = vi.fn(() => {
193 throw failure;
194 });
195 const runner = yield* CursorAgentSdk.makeCursorAgentSdkRunner(() => undefined).pipe(
196 Effect.provideService(CursorSdk.CursorSdk, {
197 load,
198 loadSqlite: () => {
199 throw new Error("unused SQLite");
200 },
201 }),
202 );
203 assert.strictEqual(load.mock.calls.length, 0);
204 const result = yield* runner
205 .open({
206 operation: "create",
207 options: {},
208 threadId: ThreadId.make("lazy-load"),
209 providerSessionId: ProviderSessionId.make("lazy-load"),
210 })
211 .pipe(Effect.exit);
212 assert(Exit.isFailure(result));
213 assert.deepStrictEqual(
214 Cause.squash(result.cause),
215 new CursorAgentSdk.CursorAgentSdkRunnerError({
216 method: "agent.create",
217 cause: failure,
218 }),
219 );
220 assert.strictEqual(load.mock.calls.length, 1);
221 }),
222 );
223​
224 it.effect("retries a resumed send after cancelling its abandoned local run", () =>
225 Effect.gen(function* () {
226 cursorSdkMock.runOperations.length = 0;
227 cursorSdkMock.listRuns.mockClear();
228 cursorSdkMock.cancelRun.mockClear();
229 cursorSdkMock.setStoredRuns([
230 ...Array.from({ length: 50 }, (_, index) => ({
231 id: `run-terminal-${index}`,
232 status: index === 0 ? "cancelled" : "finished",
233 })),
234 { id: "run-stale", status: "running" },
235 ]);
236​
237 const runner = yield* CursorAgentSdk.CursorAgentSdkRunner;
238 const session = yield* runner.open({
239 operation: "resume",
240 agentId: "agent-cursor-agent-sdk-test",
241 options: { model: { id: "default" }, mode: "agent", local: { cwd: process.cwd() } },
242 threadId: ThreadId.make("thread-cursor-agent-sdk-stale-run-test"),
243 providerSessionId: ProviderSessionId.make(
244 "provider-session-cursor-agent-sdk-stale-run-test",
245 ),
246 });
247 assert.deepStrictEqual(cursorSdkMock.runOperations, []);
248 const run = yield* session.send({ message: "continue" });
249 yield* run.wait;
250​
251 assert.deepStrictEqual(cursorSdkMock.runOperations, [
252 "run.start",
253 "agent.listRuns",
254 "agent.listRuns",
255 "agent.cancelRun",
256 "run.start",
257 ]);
258 assert.deepStrictEqual(cursorSdkMock.listRuns.mock.calls[0], [
259 "agent-cursor-agent-sdk-test",
260 { runtime: "local", cwd: process.cwd() },
261 ]);
262 assert.strictEqual(cursorSdkMock.listRuns.mock.calls.length, 2);
263 assert.deepStrictEqual(cursorSdkMock.listRuns.mock.calls[1], [
264 "agent-cursor-agent-sdk-test",
265 { runtime: "local", cwd: process.cwd(), cursor: "50" },
266 ]);
267 assert.deepStrictEqual(cursorSdkMock.cancelRun.mock.calls[0], [
268 "run-stale",
269 {
270 runtime: "local",
271 cwd: process.cwd(),
272 },
273 ]);
274 }).pipe(Effect.provide(layerTest)),
275 );
276​
277 it.effect("forwards the agent's own shell stream output as shell-output-delta", () =>
278 Effect.gen(function* () {
279 const publish = (conversationId: string, data: string) =>
280 (Reflect.get(globalThis, Symbol.for("@cursor/sdk.shellStreamOutput")) as Function)({
281 conversationId,
282 toolCallId: "tool-shell",
283 event: { case: "stdout", value: { data } },
284 });
285 cursorSdkMock.runWait.mockImplementationOnce(async () => {
286 publish("agent-cursor-agent-sdk-test", "tick 1\n");
287 publish("agent-someone-else", "not ours\n");
288 return {
289 id: "run-cursor-agent-sdk-test",
290 requestId: "request-cursor-agent-sdk-test",
291 status: "finished",
292 result: "ok",
293 model: { id: "default" },
294 durationMs: 1,
295 };
296 });
297 const updates: Array<unknown> = [];
298 const runner = yield* CursorAgentSdk.CursorAgentSdkRunner;
299 const session = yield* runner.open({
300 operation: "create",
301 options: {},
302 threadId: ThreadId.make("thread-cursor-agent-sdk-shell"),
303 providerSessionId: ProviderSessionId.make("provider-session-cursor-agent-sdk-shell"),
304 });
305 const run = yield* session.send({
306 message: "run it",
307 onDelta: (update) => Effect.sync(() => updates.push(update)),
308 });
309 yield* run.wait;
310 publish("agent-cursor-agent-sdk-test", "after the run\n");
311​
312 assert.deepStrictEqual(
313 updates.filter(
314 (update) => (update as { readonly type: string }).type === "shell-output-delta",
315 ),
316 [
317 {
318 type: "shell-output-delta",
319 event: { case: "stdout", value: { data: "tick 1\n" }, toolCallId: "tool-shell" },
320 },
321 ],
322 );
323 }).pipe(Effect.provide(layerTest)),
324 );
325​
326 it.effect("forwards Run.steer and reports a missing method as a follow-up", () =>
327 Effect.gen(function* () {
328 cursorSdkMock.runSteer.mockClear();
329 const runner = yield* CursorAgentSdk.CursorAgentSdkRunner;
330 const session = yield* runner.open({
331 operation: "create",
332 options: {},
333 threadId: ThreadId.make("thread-cursor-agent-sdk-steer"),
334 providerSessionId: ProviderSessionId.make("provider-session-cursor-agent-sdk-steer"),
335 });
336 const run = yield* session.send({ message: "continue" });
337 assert.equal(yield* run.steer!("keep going"), "complete_delivered");
338 assert.deepStrictEqual(cursorSdkMock.runSteer.mock.calls, [["keep going"]]);
339​
340 cursorSdkMock.runSteer.mockImplementationOnce(async () => {
341 throw new Error("steer transport failed");
342 });
343 const failed = yield* run.steer!("again").pipe(Effect.exit);
344 assert(Exit.isFailure(failed));
345​
346 cursorSdkMock.send.mockImplementationOnce(async () => ({
347 id: "run-without-steer",
348 agentId: "agent-cursor-agent-sdk-test",
349 wait: cursorSdkMock.runWait,
350 cancel: cursorSdkMock.runCancel,
351 steer: undefined,
352 }));
353 const plain = yield* session.send({ message: "plain" });
354 assert.equal(yield* plain.steer!("later"), "revert_to_followup");
355 }).pipe(Effect.provide(layerTest)),
356 );
357});