1import * as NodeRuntime from "@effect/platform-node/NodeRuntime";
2import * as NodeServices from "@effect/platform-node/NodeServices";
3import * as Config from "effect/Config";
4import * as Console from "effect/Console";
5import * as Effect from "effect/Effect";
6import * as Layer from "effect/Layer";
7import * as FileSystem from "effect/FileSystem";
8import * as Option from "effect/Option";
9import * as Path from "effect/Path";
10import * as Redacted from "effect/Redacted";
11import * as Schema from "effect/Schema";
12import * as Struct from "effect/Struct";
13import { Command, Flag } from "effect/cli";
14import * as CursorSdk from "@t3tools/provider-cursor/server/CursorSdk";
15
16import {
17 recordCursorAgentSdkReplayTranscript,
18 type CursorAgentSdkReplayTranscript,
19} from "../src/orchestration-v2/Adapters/CursorAdapterV2.testkit.ts";
20import { checkpointWorkspace } from "@t3tools/provider-testing/replayWorkspace";
21import {
22 CURSOR_MODEL_SELECTION,
23 MESSAGE_STEERING_INITIAL_PROMPT,
24 MESSAGE_STEERING_STEER_PROMPT,
25 MULTI_TURN_FIRST_PROMPT,
26 MULTI_TURN_SECOND_PROMPT,
27 PROVIDER_THREAD_RESUME_FIRST_PROMPT,
28 PROVIDER_THREAD_RESUME_SECOND_PROMPT,
29 PROPOSED_PLAN_PROMPT,
30 READ_ONLY_NEVER_POLICY,
31 SIMPLE_PROMPT,
32 SKILL_INVOCATION_CURSOR_MESSAGE,
33 SUBAGENT_PROMPT,
34 TODO_LIST_PROMPT,
35 TOOL_CALL_READ_ONLY_PROMPT,
36 TURN_INTERRUPT_MID_TOOL_PROMPT,
37 WORKSPACE_NEVER_POLICY,
38} from "../src/orchestration-v2/testkit/fixtures/shared.ts";
39import { skillInvocationInput } from "../src/orchestration-v2/testkit/fixtures/skill_invocation/input.ts";
40import {
41 cursorReplayPromptsForWorkspace,
42 cursorReplayTranscriptCwd,
43 shouldSeedCursorReplayWorkspace,
44} from "./cursorReplayRecordingWorkspace.ts";
45
46const RECORDINGS = {
47 simple: {
48 prompts: [SIMPLE_PROMPT],
49 output: "../src/orchestration-v2/testkit/fixtures/simple/cursor_transcript.ndjson",
50 },
51 multi_turn: {
52 prompts: [MULTI_TURN_FIRST_PROMPT, MULTI_TURN_SECOND_PROMPT],
53 output: "../src/orchestration-v2/testkit/fixtures/multi_turn/cursor_transcript.ndjson",
54 },
55 message_steering: {
56 prompts: [MESSAGE_STEERING_INITIAL_PROMPT, MESSAGE_STEERING_STEER_PROMPT],
57 output: "../src/orchestration-v2/testkit/fixtures/message_steering/cursor_transcript.ndjson",
58 interruptAfterRunStartPromptIndex: 0,
59 },
60 provider_thread_resume: {
61 prompts: [PROVIDER_THREAD_RESUME_FIRST_PROMPT, PROVIDER_THREAD_RESUME_SECOND_PROMPT],
62 output:
63 "../src/orchestration-v2/testkit/fixtures/provider_thread_resume/cursor_transcript.ndjson",
64 restartBeforePromptIndex: 1,
65 },
66 queued_turn: {
67 prompts: [MULTI_TURN_FIRST_PROMPT, MULTI_TURN_SECOND_PROMPT],
68 output: "../src/orchestration-v2/testkit/fixtures/queued_turn/cursor_transcript.ndjson",
69 },
70 proposed_plan: {
71 prompts: [PROPOSED_PLAN_PROMPT],
72 output: "../src/orchestration-v2/testkit/fixtures/proposed_plan/cursor_transcript.ndjson",
73 interactionMode: "plan",
74 runtimePolicyOverride: READ_ONLY_NEVER_POLICY,
75 },
76 todo_list: {
77 prompts: [TODO_LIST_PROMPT],
78 output: "../src/orchestration-v2/testkit/fixtures/todo_list/cursor_transcript.ndjson",
79 runtimePolicyOverride: READ_ONLY_NEVER_POLICY,
80 },
81 subagent: {
82 prompts: [SUBAGENT_PROMPT],
83 output: "../src/orchestration-v2/testkit/fixtures/subagent/cursor_transcript.ndjson",
84 runtimePolicyOverride: READ_ONLY_NEVER_POLICY,
85 },
86 tool_call_read_only: {
87 prompts: [TOOL_CALL_READ_ONLY_PROMPT],
88 output: "../src/orchestration-v2/testkit/fixtures/tool_call_read_only/cursor_transcript.ndjson",
89 runtimePolicyOverride: READ_ONLY_NEVER_POLICY,
90 },
91 turn_interrupt_mid_tool: {
92 prompts: [TURN_INTERRUPT_MID_TOOL_PROMPT],
93 output:
94 "../src/orchestration-v2/testkit/fixtures/turn_interrupt_mid_tool/cursor_transcript.ndjson",
95 interruptAfterToolStart: true,
96 runtimePolicyOverride: WORKSPACE_NEVER_POLICY,
97 },
98 skill_invocation: {
99 // The adapter rewrites a discovered `$review` mention to Cursor's native
100 // `/review` invocation before sending, so the SDK sees the rewritten form.
101 prompts: [SKILL_INVOCATION_CURSOR_MESSAGE],
102 output: "../src/orchestration-v2/testkit/fixtures/skill_invocation/cursor_transcript.ndjson",
103 workspaceFiles: skillInvocationInput().workspaceFiles,
104 },
105} as const;
106
107type RecordingName = keyof typeof RECORDINGS;
108const RECORDING_NAMES = Struct.keys(RECORDINGS);
109
110const encodeUnknownJsonString = Schema.encodeSync(Schema.fromJsonString(Schema.Unknown));
111
112function encodeTranscriptNdjson(transcript: CursorAgentSdkReplayTranscript): string {
113 const { entries, ...metadata } = transcript;
114 return [
115 encodeUnknownJsonString({ type: "transcript_start", ...metadata }),
116 ...entries.map((entry) => encodeUnknownJsonString(entry)),
117 "",
118 ].join("\n");
119}
120
121const writeFixtureFiles = Effect.fn("writeFixtureFiles")(function* (cwd: string) {
122 const fs = yield* FileSystem.FileSystem;
123 const path = yield* Path.Path;
124 yield* fs.writeFileString(
125 path.join(cwd, "package.json"),
126 encodeUnknownJsonString({
127 name: "cursor-read-only-fixture",
128 private: true,
129 scripts: { typecheck: "tsc --noEmit" },
130 }),
131 );
132 yield* fs.writeFileString(
133 path.join(cwd, "tsconfig.json"),
134 encodeUnknownJsonString({
135 compilerOptions: {
136 module: "ESNext",
137 strict: true,
138 target: "ES2022",
139 },
140 }),
141 );
142});
143
144const recordScenario = Effect.fn("recordScenario")(function* (
145 scenario: RecordingName,
146 out: string | undefined,
147) {
148 const fs = yield* FileSystem.FileSystem;
149 const path = yield* Path.Path;
150 const apiKey = yield* Config.Redacted("CURSOR_API_KEY");
151 const model = yield* Config.NonEmptyString("T3_CURSOR_REPLAY_MODEL").pipe(
152 Config.withDefault(CURSOR_MODEL_SELECTION.model),
153 );
154 const configuredCwd = yield* Config.NonEmptyString("T3_CURSOR_REPLAY_CWD").pipe(Config.option);
155 const recording = RECORDINGS[scenario];
156
157 const owned = Option.isNone(configuredCwd);
158 const cwd = Option.isSome(configuredCwd)
159 ? configuredCwd.value
160 : yield* checkpointWorkspace(
161 `cursor-agent-sdk-record-${scenario}`,
162 "workspaceFiles" in recording ? recording.workspaceFiles : undefined,
163 );
164 if (shouldSeedCursorReplayWorkspace({ scenario, owned })) {
165 yield* writeFixtureFiles(cwd);
166 }
167 const transcriptCwd = cursorReplayTranscriptCwd(scenario);
168 const transcript = yield* recordCursorAgentSdkReplayTranscript({
169 scenario,
170 prompts: cursorReplayPromptsForWorkspace({
171 scenario,
172 configuredPrompts: recording.prompts,
173 packageJsonPath: path.join(cwd, "package.json"),
174 tsconfigPath: path.join(cwd, "tsconfig.json"),
175 }),
176 ...(scenario === "tool_call_read_only" ? { transcriptPrompts: recording.prompts } : {}),
177 modelSelection: { ...CURSOR_MODEL_SELECTION, model },
178 cwd,
179 ...(transcriptCwd === undefined ? {} : { transcriptCwd }),
180 apiKey: Redacted.value(apiKey),
181 ...("interactionMode" in recording ? { interactionMode: recording.interactionMode } : {}),
182 ...("runtimePolicyOverride" in recording
183 ? { runtimePolicyOverride: recording.runtimePolicyOverride }
184 : {}),
185 ...("interruptAfterToolStart" in recording
186 ? { interruptAfterToolStart: recording.interruptAfterToolStart }
187 : {}),
188 ...("interruptAfterRunStartPromptIndex" in recording
189 ? { interruptAfterRunStartPromptIndex: recording.interruptAfterRunStartPromptIndex }
190 : {}),
191 ...("restartBeforePromptIndex" in recording
192 ? { restartBeforePromptIndex: recording.restartBeforePromptIndex }
193 : {}),
194 });
195
196 const outputPath = out ?? (yield* path.fromFileUrl(new URL(recording.output, import.meta.url)));
197 yield* fs.makeDirectory(path.dirname(outputPath), { recursive: true });
198 yield* fs.writeFileString(outputPath, encodeTranscriptNdjson(transcript));
199 yield* Console.log(
200 `Wrote ${transcript.entries.length} Cursor SDK replay entries to ${outputPath}`,
201 );
202}, Effect.scoped);
203
204const recordCursorReplayCommand = Command.make(
205 "record-cursor-agent-sdk-replay-fixture",
206 {
207 scenario: Flag.Literals("scenario", RECORDING_NAMES).pipe(
208 Flag.withDescription("Scenario to record (or T3_CURSOR_REPLAY_SCENARIO)."),
209 Flag.withFallbackConfig(Config.Literals(RECORDING_NAMES, "T3_CURSOR_REPLAY_SCENARIO")),
210 ),
211 out: Flag.String("out").pipe(
212 Flag.optional,
213 Flag.withDescription("Write the transcript here instead of over the checked-in fixture."),
214 ),
215 },
216 ({ scenario, out }) => recordScenario(scenario, Option.getOrUndefined(out)),
217).pipe(
218 Command.withDescription(
219 "Record a Cursor Agent SDK replay fixture live. Reads CURSOR_API_KEY, plus optional T3_CURSOR_REPLAY_MODEL and T3_CURSOR_REPLAY_CWD.",
220 ),
221);
222
223Command.run(recordCursorReplayCommand, { version: "0.0.0" }).pipe(
224 Effect.provide(Layer.mergeAll(CursorSdk.layer, NodeServices.layer)),
225 NodeRuntime.runMain,
226);