1316 lines · 50.4 KB · TypeScript
RawDownload
// @effect-diagnostics nodeBuiltinImport:offimport * as path from "node:path";import * as os from "node:os";import { chmod, mkdtemp, readFile, writeFile } from "node:fs/promises";import { fileURLToPath } 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 { createModelSelection } from "@t3tools/shared/model"; import {  ApprovalRequestId,  CursorSettings,  ProviderDriverKind,  type ProviderRuntimeEvent,  ThreadId,  ProviderInstanceId,} from "@t3tools/contracts"; import { ServerConfig } from "../../config.ts";import { ServerSettingsService } from "../../serverSettings.ts";import type { CursorAdapterShape } from "../Services/CursorAdapter.ts";import { makeCursorAdapter } from "./CursorAdapter.ts";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 = path.dirname(fileURLToPath(import.meta.url));const mockAgentPath = path.join(__dirname, "../../../scripts/acp-mock-agent.ts");const bunExe = "bun"; async function makeMockAgentWrapper(  extraEnv?: Record<string, string>,  options?: { initialDelaySeconds?: number },) {  const dir = await mkdtemp(path.join(os.tmpdir(), "cursor-acp-mock-"));  const wrapperPath = path.join(dir, "fake-agent.sh");  const envExports = Object.entries(extraEnv ?? {})    .map(([key, value]) => `export ${key}=${JSON.stringify(value)}`)    .join("\n");  const script = `#!/bin/sh${envExports}${options?.initialDelaySeconds ? `sleep ${JSON.stringify(String(options.initialDelaySeconds))}` : ""}exec ${JSON.stringify(bunExe)} ${JSON.stringify(mockAgentPath)} "$@"`;  await writeFile(wrapperPath, script, "utf8");  await chmod(wrapperPath, 0o755);  return wrapperPath;} async function makeProbeWrapper(  requestLogPath: string,  argvLogPath: string,  extraEnv?: Record<string, string>,) {  const dir = await mkdtemp(path.join(os.tmpdir(), "cursor-acp-probe-"));  const wrapperPath = path.join(dir, "fake-agent.sh");  const envExports = Object.entries(extraEnv ?? {})    .map(([key, value]) => `export ${key}=${JSON.stringify(value)}`)    .join("\n");  const script = `#!/bin/shprintf '%s\t' "$@" >> ${JSON.stringify(argvLogPath)}printf '\n' >> ${JSON.stringify(argvLogPath)}export T3_ACP_REQUEST_LOG_PATH=${JSON.stringify(requestLogPath)}${envExports}exec ${JSON.stringify(bunExe)} ${JSON.stringify(mockAgentPath)} "$@"`;  await writeFile(wrapperPath, script, "utf8");  await chmod(wrapperPath, 0o755);  return wrapperPath;} async function readArgvLog(filePath: string) {  const raw = await 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 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 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}`);} // 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("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: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("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(() =>        mkdtemp(path.join(os.tmpdir(), "cursor-adapter-exit-log-")),      );      const exitLogPath = path.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(    "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(() =>          mkdtemp(path.join(os.tmpdir(), "cursor-adapter-concurrent-exit-log-")),        );        const exitLogPath = path.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);         const exitLog = yield* Effect.promise(() => waitForFileContent(exitLogPath));        assert.equal(exitLog.match(/SIGTERM/g)?.length ?? 0, 2);      }),  );   it.effect("rejects startSession when provider mismatches", () =>    Effect.gen(function* () {      const adapter = yield* CursorAdapter;      const result = yield* adapter        .startSession({          threadId: ThreadId.make("bad-provider"),          provider: ProviderDriverKind.make("codex"),          cwd: process.cwd(),          runtimeMode: "full-access",        })        .pipe(Effect.result);       assert.equal(result._tag, "Failure");    }),  );   it.effect("maps app plan mode onto the ACP plan session mode", () =>    Effect.gen(function* () {      const adapter = yield* CursorAdapter;      const serverSettings = yield* ServerSettingsService;      const threadId = ThreadId.make("cursor-plan-mode-probe");      const tempDir = yield* Effect.promise(() => mkdtemp(path.join(os.tmpdir(), "cursor-acp-")));      const requestLogPath = path.join(tempDir, "requests.ndjson");      const argvLogPath = path.join(tempDir, "argv.txt");      yield* Effect.promise(() => writeFile(requestLogPath, "", "utf8"));      const wrapperPath = yield* Effect.promise(() =>        makeProbeWrapper(requestLogPath, argvLogPath),      );      yield* serverSettings.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: "composer-2" },      });       yield* adapter.sendTurn({        threadId,        input: "plan this change",        attachments: [],        interactionMode: "plan",      });      yield* adapter.stopSession(threadId);       const requests = yield* Effect.promise(() => readJsonLines(requestLogPath));      const modeRequest = requests        .toReversed()        .find(          (entry) =>            entry.method === "session/set_mode" ||            (entry.method === "session/set_config_option" &&              (entry.params as Record<string, unknown> | undefined)?.configId === "mode"),        );      assert.isDefined(modeRequest);      assert.equal(        (modeRequest?.params as Record<string, unknown> | undefined)?.sessionId,        "mock-session-1",      );      assert.include(        ["architect", "plan"],        String(          (modeRequest?.params as Record<string, unknown> | undefined)?.modeId ??            (modeRequest?.params as Record<string, unknown> | undefined)?.value,        ),      );    }),  );   it.effect(    "applies initial model and mode configuration during startSession and skips repeating it on first send",    () =>      Effect.gen(function* () {        const adapter = yield* CursorAdapter;        const serverSettings = yield* ServerSettingsService;        const threadId = ThreadId.make("cursor-initial-config-probe");        const tempDir = yield* Effect.promise(() => mkdtemp(path.join(os.tmpdir(), "cursor-acp-")));        const requestLogPath = path.join(tempDir, "requests.ndjson");        const argvLogPath = path.join(tempDir, "argv.txt");        yield* Effect.promise(() => writeFile(requestLogPath, "", "utf8"));        const wrapperPath = yield* Effect.promise(() =>          makeProbeWrapper(requestLogPath, argvLogPath),        );        yield* serverSettings.updateSettings({          providers: { cursor: { binaryPath: wrapperPath } },        });         const modelSelection = createModelSelection(ProviderInstanceId.make("cursor"), "gpt-5.4", [          { id: "reasoning", value: "xhigh" },          { id: "contextWindow", value: "1m" },          { id: "fastMode", value: true },        ]);         yield* adapter.startSession({          threadId,          provider: ProviderDriverKind.make("cursor"),          cwd: process.cwd(),          runtimeMode: "full-access",          modelSelection,        });         yield* Effect.promise(() => waitForFileContent(requestLogPath));         const requestsAfterStart = yield* Effect.promise(() => readJsonLines(requestLogPath));        const configIdsAfterStart = requestsAfterStart.flatMap((entry) =>          entry.method === "session/set_config_option" &&          typeof (entry.params as Record<string, unknown> | undefined)?.configId === "string"            ? [String((entry.params as Record<string, unknown>).configId)]            : [],        );        assert.deepStrictEqual(configIdsAfterStart, [          "model",          "reasoning",          "context",          "fast",          "mode",        ]);         yield* adapter.sendTurn({          threadId,          input: "hello mock",          attachments: [],          modelSelection,          interactionMode: "default",        });        yield* adapter.stopSession(threadId);         const finalRequests = yield* Effect.promise(() => readJsonLines(requestLogPath));        const finalConfigIds = finalRequests.flatMap((entry) =>          entry.method === "session/set_config_option" &&          typeof (entry.params as Record<string, unknown> | undefined)?.configId === "string"            ? [String((entry.params as Record<string, unknown>).configId)]            : [],        );        assert.deepStrictEqual(finalConfigIds, ["model", "reasoning", "context", "fast", "mode"]);        assert.equal(finalRequests.filter((entry) => entry.method === "session/prompt").length, 1);      }),  );   it.effect(    "streams ACP tool calls and approvals on the active turn in approval-required mode",    () =>      Effect.gen(function* () {        const previousEmitToolCalls = process.env.T3_ACP_EMIT_TOOL_CALLS;        process.env.T3_ACP_EMIT_TOOL_CALLS = "1";         const adapter = yield* CursorAdapter;        const serverSettings = yield* ServerSettingsService;        const threadId = ThreadId.make("cursor-tool-call-probe");        const runtimeEvents: Array<ProviderRuntimeEvent> = [];        const settledEventTypes = new Set<string>();        const settledEventsReady = yield* Deferred.make<void>();         const wrapperPath = yield* Effect.promise(() =>          makeMockAgentWrapper({ T3_ACP_EMIT_TOOL_CALLS: "1" }),        );        yield* serverSettings.updateSettings({          providers: { cursor: { binaryPath: wrapperPath } },        });         yield* Stream.runForEach(adapter.streamEvents, (event) =>          Effect.gen(function* () {            runtimeEvents.push(event);            if (String(event.threadId) !== String(threadId)) {              return;            }            if (event.type === "request.opened" && event.requestId) {              yield* adapter.respondToRequest(                threadId,                ApprovalRequestId.make(String(event.requestId)),                "accept",              );            }            if (              event.type === "turn.completed" ||              (event.type === "item.completed" && event.payload.itemType === "command_execution") ||              event.type === "content.delta"            ) {              settledEventTypes.add(event.type);              if (settledEventTypes.size === 3) {                yield* Deferred.succeed(settledEventsReady, undefined).pipe(Effect.orDie);              }

Lines 1–500 of 1316.