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