1596 lines · 61.9 KB · TypeScript
RawDownload
// @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.