1import * as HostProcess from "@t3tools/shared/HostProcess";
2import type { InteractionUpdate, RunResult, SteerAckOutcome } from "@cursor/sdk";
3import * as NodeServices from "@effect/platform-node/NodeServices";
4import {
5 ProviderReplayEntry,
6 ProviderSessionId,
7 ThreadId,
8 type ModelSelection,
9 type ProviderReplayTranscript,
10} from "@t3tools/contracts";
11import * as Deferred from "effect/Deferred";
12import * as Effect from "effect/Effect";
13import * as Fiber from "effect/Fiber";
14import * as FileSystem from "effect/FileSystem";
15import * as Layer from "effect/Layer";
16import * as Schema from "effect/Schema";
17
18import * as TestProviderHost from "@t3tools/provider-testing/TestProviderHost";
19import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator";
20import { ProviderAdapterDriverCreateError } from "@t3tools/provider-core/server/adapterDriver";
21import * as ProviderAdapterRegistry from "../ProviderAdapterRegistry.ts";
22import type { OrchestratorV2ProviderReplayHarness } from "../testkit/ProviderReplayHarness.ts";
23import * as CursorAgentSdk from "@t3tools/provider-cursor/server/CursorAgentSdk";
24import {
25 CURSOR_DEFAULT_INSTANCE_ID,
26 cursorSdkModelSelection,
27 makeCursorAgentOptions,
28 makeCursorAdapterV2,
29} from "@t3tools/provider-cursor/testing";
30import type * as ProviderAdapter from "@t3tools/provider-core/server/ProviderAdapter";
31import type { RuntimePolicyV2Override } from "../RuntimePolicy.ts";
32
33const CursorAgentSdkReplayTranscript = Schema.Struct({
34 provider: Schema.Literal(CursorAgentSdk.CURSOR_PROVIDER),
35 protocol: Schema.Literal(CursorAgentSdk.CURSOR_AGENT_SDK_PROTOCOL),
36 version: Schema.String,
37 scenario: Schema.String,
38 metadata: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)),
39 entries: Schema.Array(ProviderReplayEntry),
40});
41export type CursorAgentSdkReplayTranscript = typeof CursorAgentSdkReplayTranscript.Type;
42const decodeCursorAgentSdkReplayTranscript = Schema.decodeUnknownEffect(
43 CursorAgentSdkReplayTranscript,
44);
45
46export class CursorReplayTranscriptDecodeError extends Schema.TaggedError<CursorReplayTranscriptDecodeError>()(
47 "CursorReplayTranscriptDecodeError",
48 {
49 driver: Schema.optional(Schema.String),
50 protocol: Schema.optional(Schema.String),
51 scenario: Schema.optional(Schema.String),
52 cause: Schema.Defect(),
53 },
54) {
55 override get message(): string {
56 return `Failed to decode Cursor Agent SDK replay transcript for scenario ${this.scenario ?? "<unknown>"}.`;
57 }
58}
59
60export class CursorReplayExhaustedError extends Schema.TaggedError<CursorReplayExhaustedError>()(
61 "CursorReplayExhaustedError",
62 {
63 scenario: Schema.String,
64 cursor: Schema.Number,
65 actual: Schema.Unknown,
66 },
67) {
68 override get message(): string {
69 return `Cursor Agent SDK replay transcript exhausted at cursor ${this.cursor} in scenario ${this.scenario}.`;
70 }
71}
72
73export class CursorReplayFrameMismatchError extends Schema.TaggedError<CursorReplayFrameMismatchError>()(
74 "CursorReplayFrameMismatchError",
75 {
76 scenario: Schema.String,
77 cursor: Schema.Number,
78 expected: Schema.Unknown,
79 actual: Schema.Unknown,
80 },
81) {
82 override get message(): string {
83 return `Cursor Agent SDK replay frame mismatch at cursor ${this.cursor} in scenario ${this.scenario}.`;
84 }
85}
86
87export class CursorReplayIncompleteError extends Schema.TaggedError<CursorReplayIncompleteError>()(
88 "CursorReplayIncompleteError",
89 {
90 scenario: Schema.String,
91 cursor: Schema.Number,
92 remaining: Schema.Number,
93 },
94) {
95 override get message(): string {
96 return `Cursor Agent SDK replay ended with ${this.remaining} unconsumed entries in scenario ${this.scenario}.`;
97 }
98}
99
100export class CursorReplayRuntimeError extends Schema.TaggedError<CursorReplayRuntimeError>()(
101 "CursorReplayRuntimeError",
102 {
103 scenario: Schema.String,
104 cursor: Schema.Number,
105 cause: Schema.Defect(),
106 },
107) {
108 override get message(): string {
109 return `Cursor Agent SDK replay failed at cursor ${this.cursor} in scenario ${this.scenario}.`;
110 }
111}
112
113export const CursorAgentSdkReplayError = Schema.Union([
114 CursorReplayTranscriptDecodeError,
115 CursorReplayExhaustedError,
116 CursorReplayFrameMismatchError,
117 CursorReplayIncompleteError,
118 CursorReplayRuntimeError,
119]);
120export type CursorAgentSdkReplayError = typeof CursorAgentSdkReplayError.Type;
121const isCursorAgentSdkReplayError = Schema.is(CursorAgentSdkReplayError);
122const isCursorAgentSdkRunnerError = Schema.is(CursorAgentSdk.CursorAgentSdkRunnerError);
123
124export const CursorOrchestratorReplayHarnessError = Schema.Union([
125 CursorAgentSdkReplayError,
126 ProviderAdapterDriverCreateError,
127]);
128export type CursorOrchestratorReplayHarnessError = typeof CursorOrchestratorReplayHarnessError.Type;
129
130type CursorProtocolPayload = CursorAgentSdk.CursorAgentSdkProtocolLogEvent["payload"];
131type CursorOutgoingFrame = Extract<
132 CursorAgentSdk.CursorAgentSdkProtocolLogEvent,
133 { readonly direction: "outgoing" }
134>["payload"];
135
136function stableStringify(value: unknown): string {
137 if (Array.isArray(value)) {
138 return `[${value.map(stableStringify).join(",")}]`;
139 }
140 if (typeof value === "object" && value !== null) {
141 const record = value as Record<string, unknown>;
142 return `{${Object.keys(record)
143 .toSorted()
144 .map((key) => `${JSON.stringify(key)}:${stableStringify(record[key])}`)
145 .join(",")}}`;
146 }
147 return JSON.stringify(value);
148}
149
150function normalizeContextHandoffText(value: string): string {
151 if (!value.startsWith("Context handoff (")) {
152 return value;
153 }
154 const marker = "\n\nUser message:\n";
155 const markerIndex = value.indexOf(marker);
156 const headerEnd = value.indexOf(":\n");
157 if (markerIndex === -1 || headerEnd === -1 || headerEnd >= markerIndex) {
158 return value;
159 }
160 return `${value.slice(0, headerEnd + 2)}<dynamic-summary>${value.slice(markerIndex)}`;
161}
162
163function normalizeFrame(value: unknown): unknown {
164 if (typeof value === "string") {
165 return normalizeContextHandoffText(value);
166 }
167 if (Array.isArray(value)) {
168 return value.map(normalizeFrame);
169 }
170 if (typeof value !== "object" || value === null) {
171 return value;
172 }
173 return Object.fromEntries(
174 Object.entries(value).map(([key, entry]) => [key, normalizeFrame(entry)]),
175 );
176}
177
178function sameFrame(left: unknown, right: unknown): boolean {
179 return stableStringify(normalizeFrame(left)) === stableStringify(normalizeFrame(right));
180}
181
182function makeSignal(): {
183 readonly promise: Promise<void>;
184 readonly resolve: () => void;
185} {
186 let resolve = () => {};
187 const promise = new Promise<void>((onResolve) => {
188 resolve = onResolve;
189 });
190 return { promise, resolve };
191}
192
193function replayRunnerError(
194 transcript: CursorAgentSdkReplayTranscript,
195 cause: unknown,
196 method: string,
197): CursorAgentSdk.CursorAgentSdkRunnerError {
198 if (isCursorAgentSdkRunnerError(cause)) {
199 return cause;
200 }
201 return new CursorAgentSdk.CursorAgentSdkRunnerError({
202 method,
203 cause: isCursorAgentSdkReplayError(cause)
204 ? cause
205 : new CursorReplayRuntimeError({
206 scenario: transcript.scenario,
207 cursor: 0,
208 cause,
209 }),
210 });
211}
212
213export function makeCursorAgentSdkReplayRunner(
214 transcript: CursorAgentSdkReplayTranscript,
215): CursorAgentSdk.CursorAgentSdkRunner["Service"] {
216 const steerAcks = new Map<string, Deferred.Deferred<SteerAckOutcome>>();
217 let cursor = 0;
218 let failure: CursorAgentSdkReplayError | null = null;
219 let cursorAdvanced = makeSignal();
220
221 const recordFailure = <Error extends CursorAgentSdkReplayError>(error: Error): Error => {
222 failure = error;
223 cursorAdvanced.resolve();
224 return error;
225 };
226
227 const fail = (error: CursorAgentSdkReplayError): never => {
228 throw recordFailure(error);
229 };
230
231 const advance = () => {
232 cursor += 1;
233 const signal = cursorAdvanced;
234 cursorAdvanced = makeSignal();
235 signal.resolve();
236 };
237
238 const assertOutbound = (actual: CursorOutgoingFrame) => {
239 if (failure !== null) {
240 throw failure;
241 }
242 const entry = transcript.entries[cursor];
243 if (entry === undefined) {
244 return fail(
245 new CursorReplayExhaustedError({
246 scenario: transcript.scenario,
247 cursor,
248 actual,
249 }),
250 );
251 }
252 if (entry.type !== "expect_outbound" || !sameFrame(entry.frame, actual)) {
253 return fail(
254 new CursorReplayFrameMismatchError({
255 scenario: transcript.scenario,
256 cursor,
257 expected: entry.type === "expect_outbound" ? entry.frame : entry,
258 actual,
259 }),
260 );
261 }
262 advance();
263 };
264
265 const consumeInbound = <Type extends CursorProtocolPayload["type"]>(
266 type: Type,
267 ): Extract<CursorProtocolPayload, { readonly type: Type }> => {
268 const entry = transcript.entries[cursor];
269 if (
270 entry === undefined ||
271 entry.type !== "emit_inbound" ||
272 typeof entry.frame !== "object" ||
273 entry.frame === null ||
274 Reflect.get(entry.frame, "type") !== type
275 ) {
276 return fail(
277 new CursorReplayFrameMismatchError({
278 scenario: transcript.scenario,
279 cursor,
280 expected: { type },
281 actual: entry,
282 }),
283 );
284 }
285 const frame = entry.frame as Extract<CursorProtocolPayload, { readonly type: Type }>;
286 advance();
287 return frame;
288 };
289
290 const waitForRun = <Error>(
291 runId: string,
292 sendInput: CursorAgentSdk.CursorAgentSdkSendInput<Error>,
293 ): Effect.Effect<RunResult, CursorAgentSdkReplayError> =>
294 Effect.gen(function* () {
295 while (true) {
296 if (failure !== null) {
297 return yield* failure;
298 }
299 const entry = transcript.entries[cursor];
300 if (entry === undefined) {
301 return yield* recordFailure(
302 new CursorReplayExhaustedError({
303 scenario: transcript.scenario,
304 cursor,
305 actual: { type: "run.completed", runId },
306 }),
307 );
308 }
309 if (entry.type === "expect_outbound") {
310 const type =
311 typeof entry.frame === "object" && entry.frame !== null
312 ? Reflect.get(entry.frame, "type")
313 : undefined;
314 if (
315 (type !== "run.cancel" && type !== "run.steer") ||
316 Reflect.get(entry.frame as object, "runId") !== runId
317 ) {
318 return yield* recordFailure(
319 new CursorReplayFrameMismatchError({
320 scenario: transcript.scenario,
321 cursor,
322 expected: { type: "run.cancel | run.steer", runId },
323 actual: entry.frame,
324 }),
325 );
326 }
327 const signal = cursorAdvanced;
328 yield* Effect.promise(() => signal.promise);
329 continue;
330 }
331 if (entry.type === "runtime_exit") {
332 advance();
333 if (entry.status === "success") {
334 continue;
335 }
336 return yield* recordFailure(
337 new CursorReplayRuntimeError({
338 scenario: transcript.scenario,
339 cursor: cursor - 1,
340 cause: entry.error ?? entry.status,
341 }),
342 );
343 }
344 if (
345 typeof entry.frame !== "object" ||
346 entry.frame === null ||
347 typeof Reflect.get(entry.frame, "type") !== "string"
348 ) {
349 return yield* recordFailure(
350 new CursorReplayFrameMismatchError({
351 scenario: transcript.scenario,
352 cursor,
353 expected: { type: "interaction.update | run.completed" },
354 actual: entry.frame,
355 }),
356 );
357 }
358 const frame = entry.frame as CursorProtocolPayload;
359 if (frame.type === "interaction.update") {
360 if (frame.runId !== runId) {
361 return yield* recordFailure(
362 new CursorReplayFrameMismatchError({
363 scenario: transcript.scenario,
364 cursor,
365 expected: { runId },
366 actual: frame,
367 }),
368 );
369 }
370 advance();
371 yield* (sendInput.onDelta?.(frame.update) ?? Effect.void).pipe(
372 Effect.mapError((cause) =>
373 recordFailure(
374 new CursorReplayRuntimeError({
375 scenario: transcript.scenario,
376 cursor: cursor - 1,
377 cause,
378 }),
379 ),
380 ),
381 );
382 yield* Effect.yieldNow;
383 continue;
384 }
385 if (frame.type === "run.steered") {
386 const ack = steerAcks.get(frame.runId);
387 if (ack === undefined || frame.runId !== runId) {
388 return yield* recordFailure(
389 new CursorReplayFrameMismatchError({
390 scenario: transcript.scenario,
391 cursor,
392 expected: { runId },
393 actual: frame,
394 }),
395 );
396 }
397 advance();
398 steerAcks.delete(runId);
399 yield* Deferred.succeed(ack, frame.outcome);
400 continue;
401 }
402 if (frame.type === "run.completed") {
403 if (frame.result.id !== runId) {
404 return yield* recordFailure(
405 new CursorReplayFrameMismatchError({
406 scenario: transcript.scenario,
407 cursor,
408 expected: { runId },
409 actual: frame.result,
410 }),
411 );
412 }
413 advance();
414 return frame.result;
415 }
416 return yield* recordFailure(
417 new CursorReplayFrameMismatchError({
418 scenario: transcript.scenario,
419 cursor,
420 expected: { type: "interaction.update | run.completed" },
421 actual: frame,
422 }),
423 );
424 }
425 });
426
427 return {
428 open: (input: CursorAgentSdk.CursorAgentSdkOpenInput) =>
429 Effect.try({
430 try: () => {
431 assertOutbound({
432 type: "agent.open",
433 operation: input.operation,
434 ...(input.agentId === undefined ? {} : { agentId: input.agentId }),
435 options: CursorAgentSdk.loggedCursorAgentOptions(input.options),
436 });
437 const opened = consumeInbound("agent.opened");
438 const session: CursorAgentSdk.CursorAgentSdkSession = {
439 agentId: opened.agentId,
440 send: (sendInput) =>
441 Effect.try({
442 try: () => {
443 assertOutbound({
444 type: "run.start",
445 message: sendInput.message,
446 options: CursorAgentSdk.loggedCursorSendOptions(sendInput.options),
447 });
448 const started = consumeInbound("run.started");
449 const run: CursorAgentSdk.CursorAgentSdkRun = {
450 runId: started.runId,
451 agentId: started.agentId,
452 wait: waitForRun(started.runId, sendInput).pipe(
453 Effect.mapError((cause) =>
454 replayRunnerError(transcript, cause, "replay.run.wait"),
455 ),
456 ),
457 steer: Effect.fn("CursorReplay.steer")(function* (text) {
458 const ack = yield* Deferred.make<SteerAckOutcome>();
459 steerAcks.set(started.runId, ack);
460 yield* Effect.try({
461 try: () =>
462 assertOutbound({ type: "run.steer", runId: started.runId, text }),
463 catch: (cause) => replayRunnerError(transcript, cause, "replay.run.steer"),
464 });
465 return yield* Deferred.await(ack);
466 }),
467 cancel: Effect.try({
468 try: () =>
469 assertOutbound({
470 type: "run.cancel",
471 runId: started.runId,
472 }),
473 catch: (cause) => replayRunnerError(transcript, cause, "replay.run.cancel"),
474 }),
475 };
476 return run;
477 },
478 catch: (cause) => replayRunnerError(transcript, cause, "replay.session.send"),
479 }),
480 listMessages: Effect.try({
481 try: () => {
482 assertOutbound({
483 type: "agent.messages.list",
484 agentId: opened.agentId,
485 });
486 return consumeInbound("agent.messages").messages;
487 },
488 catch: (cause) => replayRunnerError(transcript, cause, "replay.agent.messages.list"),
489 }),
490 close: Effect.try({
491 try: () =>
492 assertOutbound({
493 type: "agent.close",
494 agentId: opened.agentId,
495 }),
496 catch: (cause) => replayRunnerError(transcript, cause, "replay.agent.close"),
497 }),
498 };
499 return session;
500 },
501 catch: (cause) => replayRunnerError(transcript, cause, "replay.agent.open"),
502 }),
503 assertComplete: Effect.try({
504 try: () => {
505 if (failure !== null) {
506 throw failure;
507 }
508 if (cursor !== transcript.entries.length) {
509 throw new CursorReplayIncompleteError({
510 scenario: transcript.scenario,
511 cursor,
512 remaining: transcript.entries.length - cursor,
513 });
514 }
515 },
516 catch: (cause) => replayRunnerError(transcript, cause, "replay.assertComplete"),
517 }),
518 };
519}
520
521function layerCursorAgentSdkReplay(
522 transcript: CursorAgentSdkReplayTranscript,
523 options?: {
524 readonly runner?: CursorAgentSdk.CursorAgentSdkRunner["Service"];
525 readonly assertCompleteOnFinalize?: boolean;
526 },
527): Layer.Layer<CursorAgentSdk.CursorAgentSdkRunner> {
528 const runner = options?.runner ?? makeCursorAgentSdkReplayRunner(transcript);
529 return Layer.effect(
530 CursorAgentSdk.CursorAgentSdkRunner,
531 Effect.gen(function* () {
532 yield* Effect.addFinalizer(() =>
533 options?.assertCompleteOnFinalize === false
534 ? Effect.void
535 : runner.assertComplete.pipe(Effect.orDie),
536 );
537 return CursorAgentSdk.CursorAgentSdkRunner.of(runner);
538 }),
539 );
540}
541
542export function layer(
543 transcript: CursorAgentSdkReplayTranscript,
544 options?: {
545 readonly runner?: CursorAgentSdk.CursorAgentSdkRunner["Service"];
546 readonly assertCompleteOnFinalize?: boolean;
547 },
548) {
549 // Empty HOME prevents skill discovery from using the host's skills.
550 const layerHostEnvironment = Layer.effect(
551 HostProcess.Environment,
552 Effect.gen(function* () {
553 const fs = yield* FileSystem.FileSystem;
554 const home = yield* fs.makeTempDirectoryScoped({ prefix: "t3-cursor-replay-home-" });
555 return { HOME: home };
556 }).pipe(Effect.orDie),
557 ).pipe(Layer.provide(NodeServices.layer));
558 return ProviderAdapterRegistry.layerFromAdaptersEffect(
559 Effect.gen(function* () {
560 const adapter = yield* makeCursorAdapterV2({
561 instanceId: CURSOR_DEFAULT_INSTANCE_ID,
562 environment: yield* HostProcess.Environment,
563 });
564 return [adapter];
565 }),
566 ).pipe(
567 Layer.provide(
568 Layer.mergeAll(
569 layerCursorAgentSdkReplay(transcript, options),
570 TestProviderHost.layer().pipe(Layer.provide(NodeServices.layer)),
571 layerHostEnvironment,
572 NodeServices.layer,
573 IdAllocator.layer,
574 ),
575 ),
576 );
577}
578
579function metadataFromTranscript(transcript: ProviderReplayTranscript): {
580 readonly provider?: string;
581 readonly protocol?: string;
582 readonly scenario?: string;
583} {
584 return {
585 provider: transcript.provider,
586 protocol: transcript.protocol,
587 scenario: transcript.scenario,
588 };
589}
590
591export const CursorOrchestratorReplayHarness: OrchestratorV2ProviderReplayHarness<
592 CursorAgentSdkReplayTranscript,
593 CursorOrchestratorReplayHarnessError
594> = {
595 driver: CursorAgentSdk.CURSOR_PROVIDER,
596 decodeTranscript: (transcript) =>
597 decodeCursorAgentSdkReplayTranscript(transcript).pipe(
598 Effect.mapError(
599 (cause) =>
600 new CursorReplayTranscriptDecodeError({
601 ...metadataFromTranscript(transcript),
602 cause,
603 }),
604 ),
605 ),
606 makeProviderAdapterRegistryLayer: (transcript) => layer(transcript),
607};
608
609function sanitizeReplayText(
610 text: string,
611 replacements: ReadonlyArray<readonly [string, string]>,
612): string {
613 return replacements.reduce(
614 (current, [from, to]) => (from.length === 0 ? current : current.replaceAll(from, to)),
615 text,
616 );
617}
618
619function sanitizeReplayValue(
620 value: unknown,
621 replacements: ReadonlyArray<readonly [string, string]>,
622): unknown {
623 if (typeof value === "string") {
624 return sanitizeReplayText(value, replacements);
625 }
626 if (Array.isArray(value)) {
627 return value.map((entry) => sanitizeReplayValue(entry, replacements));
628 }
629 if (typeof value !== "object" || value === null) {
630 return value;
631 }
632 return Object.fromEntries(
633 Object.entries(value).map(([key, entry]) => [
634 sanitizeReplayText(key, replacements),
635 sanitizeReplayValue(entry, replacements),
636 ]),
637 );
638}
639
640function serializeCursorRecordingError(cause: unknown): unknown {
641 if (typeof cause !== "object" || cause === null) {
642 return cause;
643 }
644 return {
645 name: typeof Reflect.get(cause, "name") === "string" ? Reflect.get(cause, "name") : "Error",
646 message:
647 typeof Reflect.get(cause, "message") === "string"
648 ? Reflect.get(cause, "message")
649 : String(cause),
650 };
651}
652
653function recordingRuntimePolicy(input: {
654 readonly cwd: string;
655 readonly interactionMode: "default" | "plan";
656 readonly override?: Pick<RuntimePolicyV2Override, "approvalPolicy" | "sandboxPolicy">;
657}): ProviderAdapter.ProviderAdapterV2RuntimePolicy {
658 return {
659 runtimeMode: "full-access",
660 interactionMode: input.interactionMode,
661 cwd: input.cwd,
662 approvalPolicy: input.override?.approvalPolicy ?? "never",
663 sandboxPolicy: input.override?.sandboxPolicy ?? {
664 type: "dangerFullAccess",
665 networkAccess: true,
666 },
667 };
668}
669
670class CursorReplayRecordingError extends Schema.TaggedError<CursorReplayRecordingError>()(
671 "CursorReplayRecordingError",
672 {
673 scenario: Schema.String,
674 reason: Schema.String,
675 },
676) {
677 override get message(): string {
678 return `Cursor Agent SDK replay recording failed in scenario ${this.scenario}: ${this.reason}`;
679 }
680}
681
682interface CursorReplayRecordingInput {
683 readonly scenario: string;
684 readonly prompts: ReadonlyArray<string>;
685 readonly transcriptPrompts?: ReadonlyArray<string>;
686 readonly modelSelection: ModelSelection;
687 readonly cwd: string;
688 readonly transcriptCwd?: string;
689 readonly interactionMode?: "default" | "plan";
690 readonly runtimePolicyOverride?: Pick<
691 RuntimePolicyV2Override,
692 "approvalPolicy" | "sandboxPolicy"
693 >;
694 readonly apiKey?: string;
695 readonly interruptAfterToolStart?: boolean;
696 readonly interruptAfterRunStartPromptIndex?: number;
697 readonly steerAfterRunStartPromptIndex?: number;
698 readonly restartBeforePromptIndex?: number;
699}
700
701export const recordCursorAgentSdkReplayTranscript = Effect.fn(
702 "recordCursorAgentSdkReplayTranscript",
703)(function* (input: CursorReplayRecordingInput) {
704 const invalid = (reason: string) =>
705 new CursorReplayRecordingError({ scenario: input.scenario, reason });
706 const outsidePrompts = (index: number | undefined) =>
707 index !== undefined && (index < 0 || index >= input.prompts.length);
708 if (
709 input.transcriptPrompts !== undefined &&
710 input.transcriptPrompts.length !== input.prompts.length
711 ) {
712 return yield* invalid("Cursor transcript prompts must match the runtime prompt count.");
713 }
714 if (input.interruptAfterToolStart === true && input.prompts.length !== 1) {
715 return yield* invalid("Cursor interrupt recordings require exactly one prompt.");
716 }
717 if (
718 input.interruptAfterToolStart === true &&
719 input.interruptAfterRunStartPromptIndex !== undefined
720 ) {
721 return yield* invalid("Cursor recordings cannot use both interrupt triggers.");
722 }
723 if (outsidePrompts(input.interruptAfterRunStartPromptIndex)) {
724 return yield* invalid("Cursor interrupt prompt index is outside the prompt list.");
725 }
726 if (outsidePrompts(input.restartBeforePromptIndex)) {
727 return yield* invalid("Cursor restart prompt index is outside the prompt list.");
728 }
729
730 const entries: Array<ProviderReplayEntry> = [];
731 const interactionMode = input.interactionMode ?? "default";
732 const threadId = ThreadId.make("thread:cursor-replay");
733 const options = makeCursorAgentOptions({
734 ...(input.apiKey === undefined ? {} : { apiKey: input.apiKey }),
735 modelSelection: input.modelSelection,
736 runtimePolicy: recordingRuntimePolicy({
737 cwd: input.cwd,
738 interactionMode,
739 ...(input.runtimePolicyOverride === undefined
740 ? {}
741 : { override: input.runtimePolicyOverride }),
742 }),
743 threadId,
744 mcpSession: undefined,
745 platform: yield* HostProcess.Platform,
746 });
747 const sendOptions = {
748 model: cursorSdkModelSelection(input.modelSelection),
749 mode: interactionMode === "plan" ? "plan" : "agent",
750 } as const;
751 // Agents search parent directories too; normalize the recording host's temp layout.
752 const replacements: ReadonlyArray<readonly [string, string]> = [
753 [input.cwd, input.transcriptCwd ?? `/tmp/cursor-replay-${input.scenario}`],
754 [input.cwd.slice(0, input.cwd.lastIndexOf("/")), "/tmp"],
755 ];
756 let runsStarted = 0;
757 let resuming = false;
758
759 const frameLabel = (frame: CursorProtocolPayload): string => {
760 const beforeNextPrompt = `before-prompt-${runsStarted + 1}`;
761 switch (frame.type) {
762 case "agent.open":
763 return resuming ? `agent.resume:${beforeNextPrompt}` : "agent.open";
764 case "agent.opened":
765 return resuming ? `agent.resumed:${beforeNextPrompt}` : "agent.opened";
766 case "agent.close":
767 return runsStarted < input.prompts.length
768 ? `agent.close:${beforeNextPrompt}`
769 : "agent.close";
770 case "interaction.update":
771 return frame.update.type;
772 default:
773 return `${frame.type}:${runsStarted}`;
774 }
775 };
776
777 const transcriptFrame = (frame: CursorProtocolPayload): CursorProtocolPayload => {
778 switch (frame.type) {
779 case "run.start":
780 return { ...frame, message: input.transcriptPrompts?.[runsStarted - 1] ?? frame.message };
781 case "interaction.update":
782 return {
783 ...frame,
784 update: sanitizeReplayValue(frame.update, replacements) as InteractionUpdate,
785 };
786 case "run.completed":
787 return { ...frame, result: sanitizeReplayValue(frame.result, replacements) as RunResult };
788 default:
789 return frame;
790 }
791 };
792
793 // Hold frames after tool-call-started until run.cancel. Append without waiting because updates can arrive during send.
794 let awaitingToolStart = input.interruptAfterToolStart === true;
795 let heldUntilCancel: Array<ProviderReplayEntry> | undefined;
796
797 const recordFrame = (event: CursorAgentSdk.CursorAgentSdkProtocolLogEvent) =>
798 Effect.sync(() => {
799 const frame = event.payload;
800 if (frame.type === "agent.open") {
801 resuming = frame.operation === "resume";
802 }
803 if (frame.type === "run.start") {
804 runsStarted += 1;
805 }
806 const entry: ProviderReplayEntry = {
807 type: event.direction === "outgoing" ? "expect_outbound" : "emit_inbound",
808 label: frameLabel(frame),
809 frame: transcriptFrame(frame),
810 };
811 if (heldUntilCancel !== undefined && frame.type !== "run.cancel") {
812 heldUntilCancel.push(entry);
813 return;
814 }
815 entries.push(entry);
816 if (frame.type === "run.cancel" && heldUntilCancel !== undefined) {
817 entries.push(...heldUntilCancel);
818 heldUntilCancel = undefined;
819 }
820 if (
821 awaitingToolStart &&
822 frame.type === "interaction.update" &&
823 frame.update.type === "tool-call-started"
824 ) {
825 awaitingToolStart = false;
826 heldUntilCancel = [];
827 }
828 });
829 const runner = yield* CursorAgentSdk.makeCursorAgentSdkRunner(() => recordFrame);
830
831 const awaitSignal = (signal: Deferred.Deferred<void>, description: string) =>
832 Deferred.await(signal).pipe(
833 Effect.timeoutOrElse({
834 duration: "30 seconds",
835 orElse: () => Effect.fail(invalid(`Timed out waiting for ${description}.`)),
836 }),
837 );
838
839 const runPrompt = Effect.fnUntraced(function* (
840 session: CursorAgentSdk.CursorAgentSdkSession,
841 prompt: string,
842 index: number,
843 ) {
844 if (
845 input.steerAfterRunStartPromptIndex !== undefined &&
846 index === input.steerAfterRunStartPromptIndex + 1
847 )
848 return;
849 const interruptAfterRunStart = input.interruptAfterRunStartPromptIndex === index;
850 const firstUpdate = yield* Deferred.make<void>();
851 const toolStarted = yield* Deferred.make<void>();
852 const cancelSent = yield* Deferred.make<void>();
853 let sent = false;
854 const run = yield* session.send({
855 message: prompt,
856 options: sendOptions,
857 onDelta: (update) =>
858 Effect.gen(function* () {
859 yield* Deferred.succeed(firstUpdate, undefined);
860 if (input.interruptAfterToolStart !== true || update.type !== "tool-call-started") {
861 return;
862 }
863 const first = yield* Deferred.succeed(toolStarted, undefined);
864 if (first && sent) {
865 yield* Deferred.await(cancelSent);
866 }
867 }),
868 });
869 sent = true;
870 const waiting = yield* run.wait.pipe(Effect.forkChild({ startImmediately: true }));
871 if (input.steerAfterRunStartPromptIndex === index) {
872 yield* awaitSignal(firstUpdate, "Cursor SDK activity before steering");
873 const steerText = input.prompts[index + 1];
874 if (run.steer === undefined || steerText === undefined)
875 return yield* invalid("Steering requires a following prompt and Run.steer.");
876 const outcome = yield* run.steer(steerText);
877 if (outcome !== "complete_delivered")
878 return yield* invalid("The running turn did not accept the steer.");
879 }
880 if (interruptAfterRunStart) {
881 yield* awaitSignal(firstUpdate, "Cursor SDK run activity before interrupt");
882 yield* run.cancel;
883 }
884 if (input.interruptAfterToolStart === true) {
885 yield* awaitSignal(toolStarted, "Cursor SDK tool-call-started before interrupt");
886 // Synchronous tool-call-started cancellation crashes @cursor/sdk with AbortError (1.0.22, 1.0.31, 1.0.32); defer 10 ms.
887 yield* Effect.sleep("10 millis");
888 const cancelling = yield* run.cancel.pipe(Effect.forkChild({ startImmediately: true }));
889 yield* Deferred.succeed(cancelSent, undefined);
890 yield* Fiber.join(cancelling);
891 }
892 yield* Fiber.join(waiting).pipe(
893 Effect.catchIf(
894 (error) =>
895 (input.interruptAfterToolStart === true || interruptAfterRunStart) &&
896 CursorAgentSdk.isCursorCancellationError(error.cause),
897 (error) =>
898 Effect.sync(() => {
899 entries.push({
900 type: "runtime_exit",
901 status: "cancelled",
902 error: serializeCursorRecordingError(error.cause),
903 });
904 }),
905 ),
906 );
907 });
908
909 const runPrompts = (session: CursorAgentSdk.CursorAgentSdkSession, from: number, to: number) =>
910 Effect.forEach(
911 input.prompts.slice(from, to),
912 (prompt, offset) => runPrompt(session, prompt, from + offset),
913 { discard: true },
914 );
915
916 const withAgent = <A, E>(
917 open:
918 | { readonly operation: "create" }
919 | { readonly operation: "resume"; readonly agentId: string },
920 use: (session: CursorAgentSdk.CursorAgentSdkSession) => Effect.Effect<A, E>,
921 ) =>
922 Effect.acquireUseRelease(
923 runner.open({
924 ...open,
925 options,
926 threadId,
927 providerSessionId: ProviderSessionId.make("provider-session:cursor-replay"),
928 }),
929 use,
930 (session) => session.close,
931 );
932
933 const restartAt = input.restartBeforePromptIndex ?? input.prompts.length;
934 const nativeAgentId = yield* withAgent({ operation: "create" }, (session) =>
935 runPrompts(session, 0, restartAt).pipe(Effect.as(session.agentId)),
936 );
937 if (restartAt < input.prompts.length) {
938 yield* withAgent({ operation: "resume", agentId: nativeAgentId }, (session) =>
939 runPrompts(session, restartAt, input.prompts.length),
940 );
941 }
942
943 return {
944 provider: CursorAgentSdk.CURSOR_PROVIDER,
945 protocol: CursorAgentSdk.CURSOR_AGENT_SDK_PROTOCOL,
946 version: "1",
947 scenario: input.scenario,
948 metadata: {
949 generatedBy: "recordCursorAgentSdkReplayTranscript",
950 nativeAgentId,
951 },
952 entries,
953 } satisfies CursorAgentSdkReplayTranscript;
954});