1432 lines · 54.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 { 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 = NodePath.dirname(NodeURL.fileURLToPath(import.meta.url));const mockAgentPath = NodePath.join(__dirname, "../../../scripts/acp-mock-agent.ts");const mockAgentCommand = "node";const mockAgentArgs = [mockAgentPath] as const; async function makeMockAgentWrapper(  extraEnv?: Record<string, string>,  options?: { initialDelaySeconds?: number },) {  const dir = await NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "cursor-acp-mock-"));  const wrapperPath = NodePath.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(mockAgentCommand)} ${mockAgentArgs.map((arg) => JSON.stringify(arg)).join(" ")} "$@"`;  await NodeFSP.writeFile(wrapperPath, script, "utf8");  await NodeFSP.chmod(wrapperPath, 0o755);  return wrapperPath;} async function makeProbeWrapper(  requestLogPath: string,  argvLogPath: string,  extraEnv?: Record<string, string>,) {  const dir = await NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "cursor-acp-probe-"));  const wrapperPath = NodePath.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(mockAgentCommand)} ${mockAgentArgs.map((arg) => JSON.stringify(arg)).join(" ")} "$@"`;  await NodeFSP.writeFile(wrapperPath, script, "utf8");  await NodeFSP.chmod(wrapperPath, 0o755);  return wrapperPath;} 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("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("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("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(    "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);         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(() =>        NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "cursor-acp-")),      );      const requestLogPath = NodePath.join(tempDir, "requests.ndjson");      const argvLogPath = NodePath.join(tempDir, "argv.txt");      yield* Effect.promise(() => NodeFSP.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(() =>          NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "cursor-acp-")),        );        const requestLogPath = NodePath.join(tempDir, "requests.ndjson");        const argvLogPath = NodePath.join(tempDir, "argv.txt");        yield* Effect.promise(() => NodeFSP.writeFile(requestLogPath, "", "utf8"));        const wrapperPath = yield* Effect.promise(() =>          makeProbeWrapper(requestLogPath, argvLogPath),        );

Lines 1–500 of 1432.