// @effect-diagnostics nodeBuiltinImport:offimport * as NodePath from "node:path";import * as NodeOS from "node:os";import * as NodeFSP from "node:fs/promises";import * as NodeURL from "node:url"; import * as NodeServices from "@effect/platform-node/NodeServices";import { assert, it } from "@effect/vitest";import * as Context from "effect/Context";import * as Deferred from "effect/Deferred";import * as Effect from "effect/Effect";import * as Fiber from "effect/Fiber";import * as Layer from "effect/Layer";import * as Schema from "effect/Schema";import * as Stream from "effect/Stream";import * as TestClock from "effect/testing/TestClock";import { createModelSelection } from "@t3tools/shared/model"; import { ApprovalRequestId, CursorSettings, ProviderDriverKind, type ProviderRuntimeEvent, ThreadId, ProviderInstanceId,} from "@t3tools/contracts"; import { ServerConfig } from "../../config.ts";import { buildRuntimeInstructions } from "../RuntimeInstructions.ts";import { ServerSettingsService } from "../../serverSettings.ts";import type { CursorAdapterShape } from "../Services/CursorAdapter.ts";import { makeCursorAdapter } from "./CursorAdapter.ts";import { execScriptSource, writeFakeCli } from "../../testUtils/fakeCli.ts";import { HostProcessPlatform } from "@t3tools/shared/hostProcess";const decodeCursorSettings = Schema.decodeSync(CursorSettings); // Test-local service tag so the rest of the file can keep using `yield* CursorAdapter`.class CursorAdapter extends Context.Service<CursorAdapter, CursorAdapterShape>()( "t3/provider/Layers/CursorAdapter.test/CursorAdapter",) {} const __dirname = NodePath.dirname(NodeURL.fileURLToPath(import.meta.url));const mockAgentPath = NodePath.join(__dirname, "../../../scripts/acp-mock-agent.ts");// Stopping a session kills the agent with SIGTERM; Windows terminates the// process instead, so the mock never sees a signal to log.const windowsHost = HostProcessPlatform.defaultValue() === "win32";async function makeMockAgentWrapper( extraEnv?: Record<string, string>, options?: { initialDelaySeconds?: number },) { const dir = await NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "cursor-acp-mock-")); return writeFakeCli({ directory: dir, name: "fake-agent", env: extraEnv ?? {}, source: execScriptSource({ scriptPath: mockAgentPath, ...(options?.initialDelaySeconds === undefined ? {} : { delayMs: Math.round(options.initialDelaySeconds * 1000) }), }), });} async function makeProbeWrapper( requestLogPath: string, argvLogPath: string, extraEnv?: Record<string, string>,) { const dir = await NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "cursor-acp-probe-")); return writeFakeCli({ directory: dir, name: "fake-agent", env: { T3_ACP_REQUEST_LOG_PATH: requestLogPath, ...extraEnv }, source: execScriptSource({ scriptPath: mockAgentPath, argvLogPath }), });} async function readArgvLog(filePath: string) { const raw = await NodeFSP.readFile(filePath, "utf8"); return raw .split("\n") .map((line) => line.trim()) .filter((line) => line.length > 0) .map((line) => line.split("\t").filter((token) => token.length > 0));} async function readJsonLines(filePath: string) { const raw = await NodeFSP.readFile(filePath, "utf8"); return raw .split("\n") .map((line) => line.trim()) .filter((line) => line.length > 0) .map((line) => JSON.parse(line) as Record<string, unknown>);} async function waitForFileContent(filePath: string, attempts = 40) { for (let attempt = 0; attempt < attempts; attempt += 1) { try { const raw = await NodeFSP.readFile(filePath, "utf8"); if (raw.trim().length > 0) { return raw; } } catch {} await Effect.runPromise(Effect.yieldNow); } throw new Error(`Timed out waiting for file content at ${filePath}`);} function waitForJsonLogMatch( filePath: string, predicate: (entry: Record<string, unknown>) => boolean, attempts = 40,) { return Effect.gen(function* () { for (let attempt = 0; attempt < attempts; attempt += 1) { const requests = yield* Effect.promise(() => readJsonLines(filePath)); if (requests.some(predicate)) { return requests; } yield* Effect.yieldNow; } return yield* Effect.promise(() => readJsonLines(filePath)); });} // Tests mutate `ServerSettingsService` mid-flight (e.g. setting// `providers.cursor.binaryPath` to a mock ACP wrapper). The adapter// captures `cursorSettings` once at construction, so without a resolver// the mutation is invisible — sessions would spawn the constructor's// (empty) binary path. Wiring `resolveSettings` through// `ServerSettingsService.getSettings` makes each session read the latest// snapshot, matching the old "always read live" behavior that these// tests assumed.const makeResolveCursorSettings = Effect.gen(function* () { const serverSettings = yield* ServerSettingsService; return yield* Effect.succeed( serverSettings.getSettings.pipe( Effect.map((snapshot) => snapshot.providers.cursor), Effect.orDie, ), );}); const cursorAdapterTestLayer = it.layer( Layer.effect( CursorAdapter, Effect.gen(function* () { const cursorConfig = decodeCursorSettings({}); const resolveSettings = yield* makeResolveCursorSettings; return yield* makeCursorAdapter(cursorConfig, { resolveSettings }); }), ).pipe( Layer.provideMerge(ServerSettingsService.layerTest()), Layer.provideMerge( ServerConfig.layerTest(process.cwd(), { prefix: "t3code-cursor-adapter-test-", }), ), Layer.provideMerge(NodeServices.layer), ),); cursorAdapterTestLayer("CursorAdapterLive", (it) => { it.effect("rejects a Cursor transport error returned as a successful assistant answer", () => Effect.gen(function* () { const adapter = yield* CursorAdapter; const settings = yield* ServerSettingsService; const threadId = ThreadId.make("cursor-transport-error-answer"); const wrapperPath = yield* Effect.promise(() => makeMockAgentWrapper({ T3_ACP_PROMPT_RESPONSE_TEXT: "Error: RetriableError: WritableIterable is closed", }), ); yield* settings.updateSettings({ providers: { cursor: { binaryPath: wrapperPath } } }); const runtimeEventsFiber = yield* adapter.streamEvents.pipe( Stream.takeUntil((event) => event.type === "session.exited"), Stream.runCollect, Effect.forkChild, ); yield* adapter.startSession({ threadId, provider: ProviderDriverKind.make("cursor"), cwd: process.cwd(), runtimeMode: "full-access", }); const error = yield* adapter .sendTurn({ threadId, input: "continue", attachments: [] }) .pipe(Effect.flip); assert.equal(error._tag, "ProviderAdapterRequestError"); if (error._tag === "ProviderAdapterRequestError") { assert.equal(error.detail, "Cursor reported a transport failure."); assert.equal(error.cause, "Error: RetriableError: WritableIterable is closed"); } yield* adapter.stopSession(threadId); const runtimeEvents = yield* Fiber.join(runtimeEventsFiber); assert.isFalse(runtimeEvents.some((event) => event.type === "turn.completed")); }), ); it.effect("starts a session and maps mock ACP prompt flow to runtime events", () => Effect.gen(function* () { const adapter = yield* CursorAdapter; const settings = yield* ServerSettingsService; const threadId = ThreadId.make("cursor-mock-thread"); const wrapperPath = yield* Effect.promise(() => makeMockAgentWrapper()); yield* settings.updateSettings({ providers: { cursor: { binaryPath: wrapperPath } } }); const runtimeEventsFiber = yield* Stream.take(adapter.streamEvents, 9).pipe( Stream.runCollect, Effect.forkChild, ); const session = yield* adapter.startSession({ threadId, provider: ProviderDriverKind.make("cursor"), cwd: process.cwd(), runtimeMode: "full-access", modelSelection: { instanceId: ProviderInstanceId.make("cursor"), model: "default" }, }); assert.equal(session.provider, "cursor"); assert.deepStrictEqual(session.resumeCursor, { schemaVersion: 1, sessionId: "mock-session-1", }); yield* adapter.sendTurn({ threadId, input: "hello mock", attachments: [], }); const runtimeEvents = Array.from(yield* Fiber.join(runtimeEventsFiber)); const types = runtimeEvents.map((e) => e.type); for (const t of [ "session.started", "session.state.changed", "thread.started", "turn.started", "turn.plan.updated", "item.started", "content.delta", "item.completed", "turn.completed", ] as const) { assert.include(types, t); } const assistantStarted = runtimeEvents.find( (event) => event.type === "item.started" && event.payload.itemType === "assistant_message", ); assert.isDefined(assistantStarted); const delta = runtimeEvents.find((e) => e.type === "content.delta"); assert.isDefined(delta); if (delta?.type === "content.delta") { assert.equal(delta.payload.delta, "hello from mock"); assert.match(String(delta.itemId), /^assistant:mock-session-1:runtime:[^:]+:segment:0$/); } const assistantCompleted = runtimeEvents.find( (event) => event.type === "item.completed" && event.payload.itemType === "assistant_message", ); assert.isDefined(assistantCompleted); const planUpdate = runtimeEvents.find((event) => event.type === "turn.plan.updated"); assert.isDefined(planUpdate); if (planUpdate?.type === "turn.plan.updated") { assert.deepStrictEqual(planUpdate.payload.plan, [ { step: "Inspect mock ACP state", status: "completed" }, { step: "Implement the requested change", status: "inProgress" }, ]); } yield* adapter.stopSession(threadId); }), ); it.effect("sends selected project skills in Cursor's native slash form", () => Effect.gen(function* () { const adapter = yield* CursorAdapter; const settings = yield* ServerSettingsService; const threadId = ThreadId.make("cursor-skill-dispatch"); const workspace = yield* Effect.promise(() => NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "cursor-skill-dispatch-")), ); const requestLogPath = NodePath.join(workspace, "requests.ndjson"); const argvLogPath = NodePath.join(workspace, "argv.txt"); const skillDirectory = NodePath.join(workspace, ".cursor", "skills", "review"); yield* Effect.promise(() => NodeFSP.mkdir(skillDirectory, { recursive: true })); yield* Effect.promise(() => NodeFSP.writeFile(NodePath.join(skillDirectory, "SKILL.md"), "# Review\n", "utf8"), ); yield* Effect.promise(() => NodeFSP.writeFile(requestLogPath, "", "utf8")); const wrapperPath = yield* Effect.promise(() => makeProbeWrapper(requestLogPath, argvLogPath), ); yield* settings.updateSettings({ providers: { cursor: { binaryPath: wrapperPath } } }); yield* adapter.startSession({ threadId, provider: ProviderDriverKind.make("cursor"), cwd: workspace, runtimeMode: "full-access", modelSelection: { instanceId: ProviderInstanceId.make("cursor"), model: "default" }, }); yield* adapter.sendTurn({ threadId, input: "please $review this", attachments: [], }); const snapshot = yield* adapter.readThread(threadId); assert.deepStrictEqual( snapshot.turns.map((turn) => turn.items), [ [ { prompt: [{ type: "text", text: "please /review this" }], result: { stopReason: "end_turn" }, }, ], ], ); yield* adapter.stopSession(threadId); const requests = yield* Effect.promise(() => readJsonLines(requestLogPath)); const promptRequests = requests.filter((entry) => entry.method === "session/prompt"); assert.deepStrictEqual( promptRequests.map( (request) => (request.params as Record<string, unknown> | undefined)?.prompt, ), [ [ { type: "text", text: "please /review this" }, { type: "text", text: buildRuntimeInstructions({ harness: "Cursor" }) }, ], ], ); }), ); it.effect("steers a running turn instead of opening a new one on mid-turn sendTurn", () => Effect.gen(function* () { const adapter = yield* CursorAdapter; const settings = yield* ServerSettingsService; const threadId = ThreadId.make("cursor-steer-thread"); // Keep the first prompt in flight long enough for the steer to land. const wrapperPath = yield* Effect.promise(() => makeMockAgentWrapper({ T3_ACP_PROMPT_DELAY_MS: "1500" }), ); yield* settings.updateSettings({ providers: { cursor: { binaryPath: wrapperPath } } }); const runtimeEventsFiber = yield* adapter.streamEvents.pipe( Stream.filter((event) => event.threadId === threadId), Stream.takeUntil((event) => event.type === "turn.completed"), Stream.runCollect, Effect.forkChild, ); yield* adapter.startSession({ threadId, provider: ProviderDriverKind.make("cursor"), cwd: process.cwd(), runtimeMode: "full-access", modelSelection: { instanceId: ProviderInstanceId.make("cursor"), model: "default" }, }); const firstTurnFiber = yield* adapter .sendTurn({ threadId, input: "run 5 commands", attachments: [], }) .pipe(Effect.forkChild); // Poll until the first prompt is in flight — sendTurn binds the active // turn id before prompting. The mock agent runs on the real clock, so // each TestClock.adjust just provides the scheduler hops for its stdio // responses to land. yield* Effect.gen(function* () { for (let attempt = 0; attempt < 200; attempt += 1) { const sessions = yield* adapter.listSessions(); const session = sessions.find((entry) => entry.threadId === threadId); if (session?.activeTurnId !== undefined) { return; } yield* TestClock.adjust("10 millis"); } throw new Error("Timed out waiting for the first prompt to be in flight."); }); // Steer: a second sendTurn while the first prompt is still in flight // continues the same turn. const steeredTurn = yield* adapter.sendTurn({ threadId, input: "actually run 15", attachments: [], }); const firstTurn = yield* Fiber.join(firstTurnFiber); assert.equal(String(steeredTurn.turnId), String(firstTurn.turnId)); const runtimeEvents = Array.from(yield* Fiber.join(runtimeEventsFiber)); const turnStartedEvents = runtimeEvents.filter((event) => event.type === "turn.started"); const turnCompletedEvents = runtimeEvents.filter((event) => event.type === "turn.completed"); // One turn boundary for the whole run: the superseded first prompt // resolving must not settle the merged turn. assert.equal(turnStartedEvents.length, 1); assert.equal(String(turnStartedEvents[0]?.turnId), String(firstTurn.turnId)); assert.equal(turnCompletedEvents.length, 1); assert.equal(String(turnCompletedEvents[0]?.turnId), String(firstTurn.turnId)); yield* adapter.stopSession(threadId); }), ); it.effect.skipIf(windowsHost)("closes the ACP child process when a session stops", () => Effect.gen(function* () { const adapter = yield* CursorAdapter; const settings = yield* ServerSettingsService; const threadId = ThreadId.make("cursor-stop-session-close"); const tempDir = yield* Effect.promise(() => NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "cursor-adapter-exit-log-")), ); const exitLogPath = NodePath.join(tempDir, "exit.log"); const wrapperPath = yield* Effect.promise(() => makeMockAgentWrapper({ T3_ACP_EXIT_LOG_PATH: exitLogPath, }), ); yield* settings.updateSettings({ providers: { cursor: { binaryPath: wrapperPath } } }); yield* adapter.startSession({ threadId, provider: ProviderDriverKind.make("cursor"), cwd: process.cwd(), runtimeMode: "full-access", modelSelection: { instanceId: ProviderInstanceId.make("cursor"), model: "default" }, }); yield* adapter.stopSession(threadId); const exitLog = yield* Effect.promise(() => waitForFileContent(exitLogPath)); assert.include(exitLog, "SIGTERM"); }), ); it.effect.skipIf(windowsHost)( "serializes concurrent startSession calls for the same thread and closes the replaced ACP session", () => Effect.gen(function* () { const adapter = yield* CursorAdapter; const settings = yield* ServerSettingsService; const threadId = ThreadId.make("cursor-concurrent-start-session"); const tempDir = yield* Effect.promise(() => NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "cursor-adapter-concurrent-exit-log-")), ); const exitLogPath = NodePath.join(tempDir, "exit.log"); const wrapperPath = yield* Effect.promise(() => makeMockAgentWrapper( { T3_ACP_EXIT_LOG_PATH: exitLogPath, }, { initialDelaySeconds: 0.2 }, ), ); yield* settings.updateSettings({ providers: { cursor: { binaryPath: wrapperPath } } }); const [firstSession, secondSession] = yield* Effect.all( [ adapter.startSession({ threadId, provider: ProviderDriverKind.make("cursor"), cwd: process.cwd(), runtimeMode: "full-access", modelSelection: { instanceId: ProviderInstanceId.make("cursor"), model: "default" }, }), adapter.startSession({ threadId, provider: ProviderDriverKind.make("cursor"), cwd: process.cwd(), runtimeMode: "full-access", modelSelection: { instanceId: ProviderInstanceId.make("cursor"), model: "default" }, }), ], { concurrency: "unbounded" }, ); assert.equal(firstSession.threadId, threadId); assert.equal(secondSession.threadId, threadId); yield* adapter.stopSession(threadId); Lines 1–500 of 1596. Load more
// @effect-diagnostics nodeBuiltinImport:off import * as NodePath from "node:path"; import * as NodeOS from "node:os"; import * as NodeFSP from "node:fs/promises"; import * as NodeURL from "node:url"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { assert, it } from "@effect/vitest"; import * as Context from "effect/Context"; import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; import * as Fiber from "effect/Fiber"; import * as Layer from "effect/Layer"; import * as Schema from "effect/Schema"; import * as Stream from "effect/Stream"; import * as TestClock from "effect/testing/TestClock"; import { createModelSelection } from "@t3tools/shared/model"; import { ApprovalRequestId, CursorSettings, ProviderDriverKind, type ProviderRuntimeEvent, ThreadId, ProviderInstanceId, } from "@t3tools/contracts"; import { ServerConfig } from "../../config.ts"; import { buildRuntimeInstructions } from "../RuntimeInstructions.ts"; import { ServerSettingsService } from "../../serverSettings.ts"; import type { CursorAdapterShape } from "../Services/CursorAdapter.ts"; import { makeCursorAdapter } from "./CursorAdapter.ts"; import { execScriptSource, writeFakeCli } from "../../testUtils/fakeCli.ts"; import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; const decodeCursorSettings = Schema.decodeSync(CursorSettings); // Test-local service tag so the rest of the file can keep using `yield* CursorAdapter`. class CursorAdapter extends Context.Service<CursorAdapter, CursorAdapterShape>()( "t3/provider/Layers/CursorAdapter.test/CursorAdapter", ) {} const __dirname = NodePath.dirname(NodeURL.fileURLToPath(import.meta.url)); const mockAgentPath = NodePath.join(__dirname, "../../../scripts/acp-mock-agent.ts"); // Stopping a session kills the agent with SIGTERM; Windows terminates the // process instead, so the mock never sees a signal to log. const windowsHost = HostProcessPlatform.defaultValue() === "win32"; async function makeMockAgentWrapper( extraEnv?: Record<string, string>, options?: { initialDelaySeconds?: number }, ) { const dir = await NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "cursor-acp-mock-")); return writeFakeCli({ directory: dir, name: "fake-agent", env: extraEnv ?? {}, source: execScriptSource({ scriptPath: mockAgentPath, ...(options?.initialDelaySeconds === undefined ? {} : { delayMs: Math.round(options.initialDelaySeconds * 1000) }), }), }); } async function makeProbeWrapper( requestLogPath: string, argvLogPath: string, extraEnv?: Record<string, string>, ) { const dir = await NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "cursor-acp-probe-")); return writeFakeCli({ directory: dir, name: "fake-agent", env: { T3_ACP_REQUEST_LOG_PATH: requestLogPath, ...extraEnv }, source: execScriptSource({ scriptPath: mockAgentPath, argvLogPath }), }); } async function readArgvLog(filePath: string) { const raw = await NodeFSP.readFile(filePath, "utf8"); return raw .split("\n") .map((line) => line.trim()) .filter((line) => line.length > 0) .map((line) => line.split("\t").filter((token) => token.length > 0)); } async function readJsonLines(filePath: string) { const raw = await NodeFSP.readFile(filePath, "utf8"); return raw .split("\n") .map((line) => line.trim()) .filter((line) => line.length > 0) .map((line) => JSON.parse(line) as Record<string, unknown>); } async function waitForFileContent(filePath: string, attempts = 40) { for (let attempt = 0; attempt < attempts; attempt += 1) { try { const raw = await NodeFSP.readFile(filePath, "utf8"); if (raw.trim().length > 0) { return raw; } } catch {} await Effect.runPromise(Effect.yieldNow); } throw new Error(`Timed out waiting for file content at ${filePath}`); } function waitForJsonLogMatch( filePath: string, predicate: (entry: Record<string, unknown>) => boolean, attempts = 40, ) { return Effect.gen(function* () { for (let attempt = 0; attempt < attempts; attempt += 1) { const requests = yield* Effect.promise(() => readJsonLines(filePath)); if (requests.some(predicate)) { return requests; } yield* Effect.yieldNow; } return yield* Effect.promise(() => readJsonLines(filePath)); }); } // Tests mutate `ServerSettingsService` mid-flight (e.g. setting // `providers.cursor.binaryPath` to a mock ACP wrapper). The adapter // captures `cursorSettings` once at construction, so without a resolver // the mutation is invisible — sessions would spawn the constructor's // (empty) binary path. Wiring `resolveSettings` through // `ServerSettingsService.getSettings` makes each session read the latest // snapshot, matching the old "always read live" behavior that these // tests assumed. const makeResolveCursorSettings = Effect.gen(function* () { const serverSettings = yield* ServerSettingsService; return yield* Effect.succeed( serverSettings.getSettings.pipe( Effect.map((snapshot) => snapshot.providers.cursor), Effect.orDie, ), ); }); const cursorAdapterTestLayer = it.layer( Layer.effect( CursorAdapter, Effect.gen(function* () { const cursorConfig = decodeCursorSettings({}); const resolveSettings = yield* makeResolveCursorSettings; return yield* makeCursorAdapter(cursorConfig, { resolveSettings }); }), ).pipe( Layer.provideMerge(ServerSettingsService.layerTest()), Layer.provideMerge( ServerConfig.layerTest(process.cwd(), { prefix: "t3code-cursor-adapter-test-", }), ), Layer.provideMerge(NodeServices.layer), ), ); cursorAdapterTestLayer("CursorAdapterLive", (it) => { it.effect("rejects a Cursor transport error returned as a successful assistant answer", () => Effect.gen(function* () { const adapter = yield* CursorAdapter; const settings = yield* ServerSettingsService; const threadId = ThreadId.make("cursor-transport-error-answer"); const wrapperPath = yield* Effect.promise(() => makeMockAgentWrapper({ T3_ACP_PROMPT_RESPONSE_TEXT: "Error: RetriableError: WritableIterable is closed", }), ); yield* settings.updateSettings({ providers: { cursor: { binaryPath: wrapperPath } } }); const runtimeEventsFiber = yield* adapter.streamEvents.pipe( Stream.takeUntil((event) => event.type === "session.exited"), Stream.runCollect, Effect.forkChild, ); yield* adapter.startSession({ threadId, provider: ProviderDriverKind.make("cursor"), cwd: process.cwd(), runtimeMode: "full-access", }); const error = yield* adapter .sendTurn({ threadId, input: "continue", attachments: [] }) .pipe(Effect.flip); assert.equal(error._tag, "ProviderAdapterRequestError"); if (error._tag === "ProviderAdapterRequestError") { assert.equal(error.detail, "Cursor reported a transport failure."); assert.equal(error.cause, "Error: RetriableError: WritableIterable is closed"); } yield* adapter.stopSession(threadId); const runtimeEvents = yield* Fiber.join(runtimeEventsFiber); assert.isFalse(runtimeEvents.some((event) => event.type === "turn.completed")); }), ); it.effect("starts a session and maps mock ACP prompt flow to runtime events", () => Effect.gen(function* () { const adapter = yield* CursorAdapter; const settings = yield* ServerSettingsService; const threadId = ThreadId.make("cursor-mock-thread"); const wrapperPath = yield* Effect.promise(() => makeMockAgentWrapper()); yield* settings.updateSettings({ providers: { cursor: { binaryPath: wrapperPath } } }); const runtimeEventsFiber = yield* Stream.take(adapter.streamEvents, 9).pipe( Stream.runCollect, Effect.forkChild, ); const session = yield* adapter.startSession({ threadId, provider: ProviderDriverKind.make("cursor"), cwd: process.cwd(), runtimeMode: "full-access", modelSelection: { instanceId: ProviderInstanceId.make("cursor"), model: "default" }, }); assert.equal(session.provider, "cursor"); assert.deepStrictEqual(session.resumeCursor, { schemaVersion: 1, sessionId: "mock-session-1", }); yield* adapter.sendTurn({ threadId, input: "hello mock", attachments: [], }); const runtimeEvents = Array.from(yield* Fiber.join(runtimeEventsFiber)); const types = runtimeEvents.map((e) => e.type); for (const t of [ "session.started", "session.state.changed", "thread.started", "turn.started", "turn.plan.updated", "item.started", "content.delta", "item.completed", "turn.completed", ] as const) { assert.include(types, t); } const assistantStarted = runtimeEvents.find( (event) => event.type === "item.started" && event.payload.itemType === "assistant_message", ); assert.isDefined(assistantStarted); const delta = runtimeEvents.find((e) => e.type === "content.delta"); assert.isDefined(delta); if (delta?.type === "content.delta") { assert.equal(delta.payload.delta, "hello from mock"); assert.match(String(delta.itemId), /^assistant:mock-session-1:runtime:[^:]+:segment:0$/); } const assistantCompleted = runtimeEvents.find( (event) => event.type === "item.completed" && event.payload.itemType === "assistant_message", ); assert.isDefined(assistantCompleted); const planUpdate = runtimeEvents.find((event) => event.type === "turn.plan.updated"); assert.isDefined(planUpdate); if (planUpdate?.type === "turn.plan.updated") { assert.deepStrictEqual(planUpdate.payload.plan, [ { step: "Inspect mock ACP state", status: "completed" }, { step: "Implement the requested change", status: "inProgress" }, ]); } yield* adapter.stopSession(threadId); }), ); it.effect("sends selected project skills in Cursor's native slash form", () => Effect.gen(function* () { const adapter = yield* CursorAdapter; const settings = yield* ServerSettingsService; const threadId = ThreadId.make("cursor-skill-dispatch"); const workspace = yield* Effect.promise(() => NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "cursor-skill-dispatch-")), ); const requestLogPath = NodePath.join(workspace, "requests.ndjson"); const argvLogPath = NodePath.join(workspace, "argv.txt"); const skillDirectory = NodePath.join(workspace, ".cursor", "skills", "review"); yield* Effect.promise(() => NodeFSP.mkdir(skillDirectory, { recursive: true })); yield* Effect.promise(() => NodeFSP.writeFile(NodePath.join(skillDirectory, "SKILL.md"), "# Review\n", "utf8"), ); yield* Effect.promise(() => NodeFSP.writeFile(requestLogPath, "", "utf8")); const wrapperPath = yield* Effect.promise(() => makeProbeWrapper(requestLogPath, argvLogPath), ); yield* settings.updateSettings({ providers: { cursor: { binaryPath: wrapperPath } } }); yield* adapter.startSession({ threadId, provider: ProviderDriverKind.make("cursor"), cwd: workspace, runtimeMode: "full-access", modelSelection: { instanceId: ProviderInstanceId.make("cursor"), model: "default" }, }); yield* adapter.sendTurn({ threadId, input: "please $review this", attachments: [], }); const snapshot = yield* adapter.readThread(threadId); assert.deepStrictEqual( snapshot.turns.map((turn) => turn.items), [ [ { prompt: [{ type: "text", text: "please /review this" }], result: { stopReason: "end_turn" }, }, ], ], ); yield* adapter.stopSession(threadId); const requests = yield* Effect.promise(() => readJsonLines(requestLogPath)); const promptRequests = requests.filter((entry) => entry.method === "session/prompt"); assert.deepStrictEqual( promptRequests.map( (request) => (request.params as Record<string, unknown> | undefined)?.prompt, ), [ [ { type: "text", text: "please /review this" }, { type: "text", text: buildRuntimeInstructions({ harness: "Cursor" }) }, ], ], ); }), ); it.effect("steers a running turn instead of opening a new one on mid-turn sendTurn", () => Effect.gen(function* () { const adapter = yield* CursorAdapter; const settings = yield* ServerSettingsService; const threadId = ThreadId.make("cursor-steer-thread"); // Keep the first prompt in flight long enough for the steer to land. const wrapperPath = yield* Effect.promise(() => makeMockAgentWrapper({ T3_ACP_PROMPT_DELAY_MS: "1500" }), ); yield* settings.updateSettings({ providers: { cursor: { binaryPath: wrapperPath } } }); const runtimeEventsFiber = yield* adapter.streamEvents.pipe( Stream.filter((event) => event.threadId === threadId), Stream.takeUntil((event) => event.type === "turn.completed"), Stream.runCollect, Effect.forkChild, ); yield* adapter.startSession({ threadId, provider: ProviderDriverKind.make("cursor"), cwd: process.cwd(), runtimeMode: "full-access", modelSelection: { instanceId: ProviderInstanceId.make("cursor"), model: "default" }, }); const firstTurnFiber = yield* adapter .sendTurn({ threadId, input: "run 5 commands", attachments: [], }) .pipe(Effect.forkChild); // Poll until the first prompt is in flight — sendTurn binds the active // turn id before prompting. The mock agent runs on the real clock, so // each TestClock.adjust just provides the scheduler hops for its stdio // responses to land. yield* Effect.gen(function* () { for (let attempt = 0; attempt < 200; attempt += 1) { const sessions = yield* adapter.listSessions(); const session = sessions.find((entry) => entry.threadId === threadId); if (session?.activeTurnId !== undefined) { return; } yield* TestClock.adjust("10 millis"); } throw new Error("Timed out waiting for the first prompt to be in flight."); }); // Steer: a second sendTurn while the first prompt is still in flight // continues the same turn. const steeredTurn = yield* adapter.sendTurn({ threadId, input: "actually run 15", attachments: [], }); const firstTurn = yield* Fiber.join(firstTurnFiber); assert.equal(String(steeredTurn.turnId), String(firstTurn.turnId)); const runtimeEvents = Array.from(yield* Fiber.join(runtimeEventsFiber)); const turnStartedEvents = runtimeEvents.filter((event) => event.type === "turn.started"); const turnCompletedEvents = runtimeEvents.filter((event) => event.type === "turn.completed"); // One turn boundary for the whole run: the superseded first prompt // resolving must not settle the merged turn. assert.equal(turnStartedEvents.length, 1); assert.equal(String(turnStartedEvents[0]?.turnId), String(firstTurn.turnId)); assert.equal(turnCompletedEvents.length, 1); assert.equal(String(turnCompletedEvents[0]?.turnId), String(firstTurn.turnId)); yield* adapter.stopSession(threadId); }), ); it.effect.skipIf(windowsHost)("closes the ACP child process when a session stops", () => Effect.gen(function* () { const adapter = yield* CursorAdapter; const settings = yield* ServerSettingsService; const threadId = ThreadId.make("cursor-stop-session-close"); const tempDir = yield* Effect.promise(() => NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "cursor-adapter-exit-log-")), ); const exitLogPath = NodePath.join(tempDir, "exit.log"); const wrapperPath = yield* Effect.promise(() => makeMockAgentWrapper({ T3_ACP_EXIT_LOG_PATH: exitLogPath, }), ); yield* settings.updateSettings({ providers: { cursor: { binaryPath: wrapperPath } } }); yield* adapter.startSession({ threadId, provider: ProviderDriverKind.make("cursor"), cwd: process.cwd(), runtimeMode: "full-access", modelSelection: { instanceId: ProviderInstanceId.make("cursor"), model: "default" }, }); yield* adapter.stopSession(threadId); const exitLog = yield* Effect.promise(() => waitForFileContent(exitLogPath)); assert.include(exitLog, "SIGTERM"); }), ); it.effect.skipIf(windowsHost)( "serializes concurrent startSession calls for the same thread and closes the replaced ACP session", () => Effect.gen(function* () { const adapter = yield* CursorAdapter; const settings = yield* ServerSettingsService; const threadId = ThreadId.make("cursor-concurrent-start-session"); const tempDir = yield* Effect.promise(() => NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "cursor-adapter-concurrent-exit-log-")), ); const exitLogPath = NodePath.join(tempDir, "exit.log"); const wrapperPath = yield* Effect.promise(() => makeMockAgentWrapper( { T3_ACP_EXIT_LOG_PATH: exitLogPath, }, { initialDelaySeconds: 0.2 }, ), ); yield* settings.updateSettings({ providers: { cursor: { binaryPath: wrapperPath } } }); const [firstSession, secondSession] = yield* Effect.all( [ adapter.startSession({ threadId, provider: ProviderDriverKind.make("cursor"), cwd: process.cwd(), runtimeMode: "full-access", modelSelection: { instanceId: ProviderInstanceId.make("cursor"), model: "default" }, }), adapter.startSession({ threadId, provider: ProviderDriverKind.make("cursor"), cwd: process.cwd(), runtimeMode: "full-access", modelSelection: { instanceId: ProviderInstanceId.make("cursor"), model: "default" }, }), ], { concurrency: "unbounded" }, ); assert.equal(firstSession.threadId, threadId); assert.equal(secondSession.threadId, threadId); yield* adapter.stopSession(threadId);