| 1 | import * as NodeRuntime from "@effect/platform-node/NodeRuntime"; |
| 2 | import * as NodeServices from "@effect/platform-node/NodeServices"; |
| 3 | import * as Config from "effect/Config"; |
| 4 | import * as Console from "effect/Console"; |
| 5 | import * as Effect from "effect/Effect"; |
| 6 | import * as Layer from "effect/Layer"; |
| 7 | import * as FileSystem from "effect/FileSystem"; |
| 8 | import * as Option from "effect/Option"; |
| 9 | import * as Path from "effect/Path"; |
| 10 | import * as Redacted from "effect/Redacted"; |
| 11 | import * as Schema from "effect/Schema"; |
| 12 | import * as Struct from "effect/Struct"; |
| 13 | import { Command, Flag } from "effect/cli"; |
| 14 | import * as CursorSdk from "@t3tools/provider-cursor/server/CursorSdk"; |
| 15 | |
| 16 | import { |
| 17 | recordCursorAgentSdkReplayTranscript, |
| 18 | type CursorAgentSdkReplayTranscript, |
| 19 | } from "../src/orchestration-v2/Adapters/CursorAdapterV2.testkit.ts"; |
| 20 | import { checkpointWorkspace } from "@t3tools/provider-testing/replayWorkspace"; |
| 21 | import { |
| 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"; |
| 39 | import { skillInvocationInput } from "../src/orchestration-v2/testkit/fixtures/skill_invocation/input.ts"; |
| 40 | import { |
| 41 | cursorReplayPromptsForWorkspace, |
| 42 | cursorReplayTranscriptCwd, |
| 43 | shouldSeedCursorReplayWorkspace, |
| 44 | } from "./cursorReplayRecordingWorkspace.ts"; |
| 45 | |
| 46 | const 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 | |
| 107 | type RecordingName = keyof typeof RECORDINGS; |
| 108 | const RECORDING_NAMES = Struct.keys(RECORDINGS); |
| 109 | |
| 110 | const encodeUnknownJsonString = Schema.encodeSync(Schema.fromJsonString(Schema.Unknown)); |
| 111 | |
| 112 | function 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 | |
| 121 | const 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 | |
| 144 | const 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 | |
| 204 | const 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 | |
| 223 | Command.run(recordCursorReplayCommand, { version: "0.0.0" }).pipe( |
| 224 | Effect.provide(Layer.mergeAll(CursorSdk.layer, NodeServices.layer)), |
| 225 | NodeRuntime.runMain, |
| 226 | ); |