511 lines · 13.9 KB · JavaScript
RawDownload
import { spawn } from "node:child_process";import fs from "node:fs/promises";import path from "node:path";import readline from "node:readline"; const DEFAULT_PROMPT_TIMEOUT_MS = 120_000;const CANCEL_AFTER_MS = 1_500; function parseArgv(argv) {  const parsed = {    outputDir: "",    workspace: process.cwd(),    model: "",    permissionOption: "allow-once",  };   for (let i = 0; i < argv.length; i += 1) {    const token = argv[i];    const next = argv[i + 1];    if ((token === "--output-dir" || token === "-o") && next) {      parsed.outputDir = next;      i += 1;      continue;    }    if ((token === "--workspace" || token === "-w") && next) {      parsed.workspace = path.resolve(next);      i += 1;      continue;    }    if ((token === "--model" || token === "-m") && next) {      parsed.model = next;      i += 1;      continue;    }    if (token === "--permission-option" && next) {      parsed.permissionOption = next;      i += 1;    }  }   return parsed;} function nowIso() {  return new Date().toISOString();} function safeJsonParse(line) {  try {    return { ok: true, value: JSON.parse(line) };  } catch (error) {    return { ok: false, error };  }} class AcpProbeClient {  #child;  #stdoutRl;  #stderrRl;  #nextId = 1;  #closed = false;  #pending = new Map();  #onMessage;  #onServerRequest;   constructor({ onMessage, onServerRequest }) {    this.#onMessage = onMessage;    this.#onServerRequest = onServerRequest;     this.#child = spawn("agent", ["acp"], {      stdio: ["pipe", "pipe", "pipe"],      env: {        ...process.env,        NO_COLOR: "1",      },    });     this.#stdoutRl = readline.createInterface({ input: this.#child.stdout });    this.#stderrRl = readline.createInterface({ input: this.#child.stderr });     this.#stdoutRl.on("line", (line) => this.#handleStdoutLine(line));    this.#stderrRl.on("line", (line) => {      this.#onMessage({        ts: nowIso(),        channel: "stderr",        line,      });    });     this.#child.once("exit", (code, signal) => {      this.#closed = true;      const reason = `ACP process exited (code=${String(code)}, signal=${String(signal)})`;      for (const [id, pending] of this.#pending.entries()) {        this.#pending.delete(id);        pending.reject(new Error(reason));      }      this.#onMessage({        ts: nowIso(),        channel: "lifecycle",        event: "exit",        code,        signal,      });    });  }   async close() {    if (this.#closed) return;    try {      this.#child.stdin.end();    } catch {      // ignored    }    this.#child.kill("SIGTERM");    await new Promise((resolve) => setTimeout(resolve, 100));    if (!this.#closed) {      this.#child.kill("SIGKILL");    }  }   async send(method, params, { timeoutMs = DEFAULT_PROMPT_TIMEOUT_MS } = {}) {    if (this.#closed) {      throw new Error("Cannot send: ACP process is already closed.");    }    const id = this.#nextId;    this.#nextId += 1;     const message = {      jsonrpc: "2.0",      id,      method,      params,    };     this.#onMessage({      ts: nowIso(),      channel: "client->server",      message,    });    this.#child.stdin.write(`${JSON.stringify(message)}\n`);     return new Promise((resolve, reject) => {      const timeout = setTimeout(() => {        this.#pending.delete(id);        reject(new Error(`Timed out waiting for response to '${method}' (id=${id}).`));      }, timeoutMs);       this.#pending.set(id, {        resolve: (value) => {          clearTimeout(timeout);          resolve(value);        },        reject: (error) => {          clearTimeout(timeout);          reject(error);        },      });    });  }   #respond(id, result) {    if (this.#closed) return;    const message = {      jsonrpc: "2.0",      id,      result,    };    this.#onMessage({      ts: nowIso(),      channel: "client->server",      message,    });    this.#child.stdin.write(`${JSON.stringify(message)}\n`);  }   #handleStdoutLine(line) {    const parsed = safeJsonParse(line);    if (!parsed.ok) {      this.#onMessage({        ts: nowIso(),        channel: "stdout-non-json",        line,      });      return;    }     const message = parsed.value;    this.#onMessage({      ts: nowIso(),      channel: "server->client",      message,    });     if (      Object.prototype.hasOwnProperty.call(message, "id") &&      (Object.prototype.hasOwnProperty.call(message, "result") ||        Object.prototype.hasOwnProperty.call(message, "error")) &&      !Object.prototype.hasOwnProperty.call(message, "method")    ) {      const pending = this.#pending.get(message.id);      if (!pending) return;      this.#pending.delete(message.id);      if (Object.prototype.hasOwnProperty.call(message, "error")) {        pending.reject(message.error);        return;      }      pending.resolve(message.result);      return;    }     if (      Object.prototype.hasOwnProperty.call(message, "id") &&      Object.prototype.hasOwnProperty.call(message, "method")    ) {      this.#onServerRequest?.(message, (result) => this.#respond(message.id, result));    }  }} function summarizeTranscript(entries) {  const summary = {    counts: {      notificationsByMethod: {},      sessionUpdateByType: {},      serverRequestsByMethod: {},      permissionDecisions: {},    },    samples: {      initializeResult: null,      authenticateResult: null,      sessionNewResult: null,      sessionPromptResultByScenario: {},      sessionUpdateByType: {},      serverRequestByMethod: {},    },    stderr: [],  };   for (const entry of entries) {    if (entry.channel === "stderr") {      summary.stderr.push(entry.line);      continue;    }    if (entry.channel !== "server->client") continue;     const message = entry.message;    if (!message || typeof message !== "object") continue;     if (typeof message.method === "string" && !Object.prototype.hasOwnProperty.call(message, "id")) {      summary.counts.notificationsByMethod[message.method] =        (summary.counts.notificationsByMethod[message.method] ?? 0) + 1;      if (message.method === "session/update") {        const updateType = message.params?.update?.sessionUpdate;        if (typeof updateType === "string") {          summary.counts.sessionUpdateByType[updateType] =            (summary.counts.sessionUpdateByType[updateType] ?? 0) + 1;          if (!summary.samples.sessionUpdateByType[updateType]) {            summary.samples.sessionUpdateByType[updateType] = message;          }        }      }      continue;    }     if (typeof message.method === "string" && Object.prototype.hasOwnProperty.call(message, "id")) {      summary.counts.serverRequestsByMethod[message.method] =        (summary.counts.serverRequestsByMethod[message.method] ?? 0) + 1;      if (!summary.samples.serverRequestByMethod[message.method]) {        summary.samples.serverRequestByMethod[message.method] = message;      }      continue;    }  }   for (const entry of entries) {    if (entry.channel !== "scenario-result") continue;    if (entry.scenario === "initialize" && !summary.samples.initializeResult) {      summary.samples.initializeResult = entry.result;      continue;    }    if (entry.scenario === "authenticate" && !summary.samples.authenticateResult) {      summary.samples.authenticateResult = entry.result;      continue;    }    if (entry.scenario === "session/new" && !summary.samples.sessionNewResult) {      summary.samples.sessionNewResult = entry.result;      continue;    }    if (entry.scenarioName) {      summary.samples.sessionPromptResultByScenario[entry.scenarioName] = entry.result;    }  }   for (const entry of entries) {    if (entry.channel !== "permission-decision") continue;    const optionId = entry.optionId;    summary.counts.permissionDecisions[optionId] =      (summary.counts.permissionDecisions[optionId] ?? 0) + 1;  }   return summary;} async function run() {  const args = parseArgv(process.argv.slice(2));  const allowedPermissionOptions = new Set(["allow-once", "allow-always", "reject-once"]);  if (!allowedPermissionOptions.has(args.permissionOption)) {    throw new Error(      `Invalid --permission-option '${args.permissionOption}'. Expected one of: ${Array.from(allowedPermissionOptions).join(", ")}`,    );  }  const stamp = nowIso().replaceAll(":", "-");  const outputDir = args.outputDir    ? path.resolve(args.outputDir)    : path.join(process.cwd(), ".tmp", "acp-probe", stamp);  await fs.mkdir(outputDir, { recursive: true });   const transcript = [];  const pushEntry = (entry) => transcript.push(entry);   let activeScenarioName = null;   const client = new AcpProbeClient({    onMessage: (entry) => {      if (        activeScenarioName &&        entry.channel === "server->client" &&        entry.message?.method === "session/update"      ) {        transcript.push({ ...entry, scenarioName: activeScenarioName });        return;      }      transcript.push(entry);    },    onServerRequest: (message, respond) => {      if (message.method === "session/request_permission") {        const defaultChoice = args.permissionOption;        pushEntry({          ts: nowIso(),          channel: "permission-decision",          requestId: message.id,          optionId: defaultChoice,          params: message.params,        });        respond({          outcome: {            outcome: "selected",            optionId: defaultChoice,          },        });        return;      }       respond({        outcome: {          outcome: "selected",          optionId: "deny",        },      });      pushEntry({        ts: nowIso(),        channel: "server-request-unhandled",        method: message.method,        requestId: message.id,        params: message.params,      });    },  });   try {    const initializeResult = await client.send("initialize", {      protocolVersion: 1,      clientCapabilities: {        fs: { readTextFile: false, writeTextFile: false },        terminal: false,      },      clientInfo: {        name: "t3-cursor-acp-probe",        version: "0.1.0",      },    });    pushEntry({      ts: nowIso(),      channel: "scenario-result",      scenario: "initialize",      result: initializeResult,    });     const authenticateResult = await client.send("authenticate", {      methodId: "cursor_login",    });    pushEntry({      ts: nowIso(),      channel: "scenario-result",      scenario: "authenticate",      result: authenticateResult,    });     const sessionParams = {      cwd: args.workspace,      mcpServers: [],      ...(args.model ? { model: args.model } : {}),    };    const sessionResult = await client.send("session/new", sessionParams);    pushEntry({      ts: nowIso(),      channel: "scenario-result",      scenario: "session/new",      result: sessionResult,    });     const sessionId = sessionResult?.sessionId;    if (typeof sessionId !== "string" || sessionId.length === 0) {      throw new Error(`Missing sessionId from session/new response: ${JSON.stringify(sessionResult)}`);    }     const scenarios = [      {        name: "hello",        prompt: "Say hello in one sentence.",      },      {        name: "tooling",        prompt:          "Use tools to run `pwd` and then `ls -1 | head -n 8`, and summarize what you found in one paragraph.",      },      {        name: "cancel",        prompt:          "Think for a while and draft a long detailed migration plan with at least 20 bullet points before answering.",        cancelAfterMs: CANCEL_AFTER_MS,      },    ];     for (const scenario of scenarios) {      activeScenarioName = scenario.name;      const promptParams = {        sessionId,        prompt: [{ type: "text", text: scenario.prompt }],      };       const promptPromise = client.send("session/prompt", promptParams, {        timeoutMs: DEFAULT_PROMPT_TIMEOUT_MS,      });       if (scenario.cancelAfterMs) {        setTimeout(() => {          client            .send("session/cancel", { sessionId }, { timeoutMs: 15_000 })            .then((cancelResult) => {              pushEntry({                ts: nowIso(),                channel: "scenario-result",                scenario: "session/cancel",                scenarioName: scenario.name,                result: cancelResult,              });            })            .catch((error) => {              pushEntry({                ts: nowIso(),                channel: "scenario-error",                scenario: "session/cancel",                scenarioName: scenario.name,                error:                  error instanceof Error                    ? error.message                    : typeof error === "string"                      ? error                      : JSON.stringify(error),              });            });        }, scenario.cancelAfterMs);      }       const promptResult = await promptPromise;      pushEntry({        ts: nowIso(),        channel: "scenario-result",        scenario: "session/prompt",        scenarioName: scenario.name,        result: promptResult,      });      activeScenarioName = null;      await new Promise((resolve) => setTimeout(resolve, 500));    }  } finally {    await client.close();  }   const summary = summarizeTranscript(transcript);  const transcriptPath = path.join(outputDir, "transcript.ndjson");  const summaryPath = path.join(outputDir, "summary.json");   await fs.writeFile(    transcriptPath,    `${transcript.map((entry) => JSON.stringify(entry)).join("\n")}\n`,    "utf8",  );  await fs.writeFile(summaryPath, `${JSON.stringify(summary, null, 2)}\n`, "utf8");

Lines 1–500 of 511.