History

chore: move cursor acp probe artifacts to cursor stack

Julius Marminge committed 8 months ago

Commit 2435a9c1 parent 78d53063 files · +1 −840Browse files

Unified · Split

3 files
  1. .plans/18-cursor-agent-provider.md +0 −327
  2. package.json +1 −2
  3. scripts/cursor-acp-probe.mjs +0 −511
.plans/18-cursor-agent-provider.md+0−327
@@ -1,327 +0,0 @@
1−# Plan: Cursor ACP (`agent acp`) Provider Integration
2−
3−## Goal
4−
5−Add Cursor as a first-class provider in T3 Code using ACP (`agent acp`) over JSON-RPC 2.0 stdio, with robust session lifecycle handling and canonical `ProviderRuntimeEvent` projection.
6−
7−---
8−
9−## 1) Exploration Findings (from live ACP probes)
10−
11−### 1.1 Core invocation and transport
12−
13−1. Binary is `agent` on PATH (`2026.02.27-e7d2ef6` observed).
14−2. ACP server command is `agent acp`.
15−3. Transport is newline-delimited JSON-RPC 2.0 over stdio.
16−4. Messages:
17− - client -> server: requests and responses to server-initiated requests
18− - server -> client: responses, notifications (`session/update`), and server requests (`session/request_permission`)
19−
20−### 1.2 Handshake and session calls observed
21−
22−1. `initialize` returns:
23− - `protocolVersion`
24− - `agentCapabilities` (`loadSession`, `mcpCapabilities`, `promptCapabilities`)
25− - `authMethods` (includes `cursor_login`)
26−2. `authenticate { methodId: "cursor_login" }` returns `{}` when logged in.
27−3. `session/new` returns:
28− - `sessionId`
29− - `modes` (`agent`, `plan`, `ask`)
30−4. `session/load` works and requires `sessionId`, `cwd`, `mcpServers`.
31−5. `session/prompt` returns terminal response `{ stopReason: "end_turn" | "cancelled" }`.
32−
33−Important sequence note:
34−1. ACP currently allows `session/new` even without explicit `initialize`/`authenticate` when local auth already exists.
35−2. For adapter consistency and forward compatibility, we should still send `initialize` and `authenticate` during startup.
36−
37−### 1.3 `session/update` event families observed
38−
39−Observed `params.update.sessionUpdate` values:
40−
41−1. `available_commands_update`
42−2. `agent_thought_chunk`
43−3. `agent_message_chunk`
44−4. `tool_call`
45−5. `tool_call_update`
46−
47−Observed payload behavior:
48−
49−1. `agent_*_chunk` provides `content: { type: "text", text: string }`.
50−2. `tool_call` may be emitted multiple times for same `toolCallId`:
51− - initial generic form (`title: "Terminal"`, `rawInput: {}`)
52− - enriched form (`title: "\`pwd\`"`, `rawInput: { command: "pwd" }`)
53−3. `tool_call_update` statuses observed:
54− - `in_progress`
55− - `completed`
56−4. `tool_call_update` on completion may include `rawOutput`:
57− - terminal: `{ exitCode, stdout, stderr }`
58− - search/find: `{ totalFiles, truncated }`
59−
60−### 1.4 Permission flow observed
61−
62−1. ACP server sends `session/request_permission` (JSON-RPC request with `id`).
63−2. Request shape includes:
64− - `params.sessionId`
65− - `params.toolCall`
66− - `params.options` (`allow-once`, `allow-always`, `reject-once`)
67−3. Client must respond on same `id` with:
68− - `{ outcome: { outcome: "selected", optionId: "<one-option-id>" } }`
69−4. Reject path still results in tool lifecycle completion events (`tool_call_update status: completed`), typically without `rawOutput`.
70−
71−### 1.5 Error and capability quirks
72−
73−1. `session/cancel` currently returns:
74− - JSON-RPC error `-32601` Method not found
75−2. Error shape examples:
76− - unknown auth method: `-32602`
77− - `session/load` missing/invalid params: `-32602`
78− - `session/prompt` unknown session: `-32603` with details
79−3. Parallel prompts on same session are effectively single-flight:
80− - second prompt can cause first to complete with `stopReason: "cancelled"`.
81−4. `session/new` accepts a `model` field (no explicit echo in response).
82−
83−Probe artifacts:
84−1. `.tmp/acp-probe/*/transcript.ndjson`
85−2. `.tmp/acp-probe/*/summary.json`
86−3. `scripts/cursor-acp-probe.mjs`
87−
88−---
89−
90−## 2) Integration Constraints for T3
91−
92−1. T3 adapter contract still requires:
93− - `startSession`, `sendTurn`, `interruptTurn`, `respondToRequest`, `readThread`, `rollbackThread`, `stopSession`, `listSessions`, `hasSession`, `stopAll`, `streamEvents`.
94−2. Orchestration consumes canonical `ProviderRuntimeEvent` only.
95−3. `ProviderCommandReactor` provider precedence fix remains required (respect explicit provider on turn start).
96−4. ACP now supports external permission decisions, so Cursor can participate in T3 approval UX via adapter-managed request/response plumbing.
97−
98−---
99−
100−## 3) Proposed Architecture
101−
102−### 3.1 New server components
103−
104−1. `apps/server/src/provider/Services/CursorAdapter.ts` (service contract/tag + ACP event schemas).
105−2. `apps/server/src/provider/Layers/CursorAdapter.ts` (single implementation unit; owns ACP process lifecycle, JSON-RPC routing, runtime projection).
106−3. No manager indirection; keep logic in layer implementation.
107−
108−### 3.2 Session model
109−
110−1. One long-lived ACP child process per T3 Cursor provider session.
111−2. Track:
112− - `providerSessionId` (T3 synthetic ID)
113− - `acpSessionId` (from `session/new` or restored via `session/load`)
114− - `cwd`, `model`, in-flight turn state
115− - pending permission requests by JSON-RPC request id
116−3. Resume support:
117− - persist `acpSessionId` in provider resume metadata and call `session/load` on reattach.
118−
119−### 3.3 Command strategy
120−
121−1. `startSession`:
122− - spawn `agent acp`
123− - `initialize`
124− - `authenticate(cursor_login)` (best-effort, typed failure handling)
125− - `session/new` or `session/load`
126−2. `sendTurn`:
127− - send `session/prompt { sessionId, prompt: [...] }`
128− - consume streaming `session/update` notifications until terminal prompt response
129−3. `interruptTurn`:
130− - no native `session/cancel` today; implement fallback:
131− - terminate ACP process + restart + `session/load` for subsequent turns
132− - mark in-flight turn as interrupted/failed in canonical events
133−4. `respondToRequest`:
134− - map T3 approval decision -> ACP `optionId`
135− - reply to exact JSON-RPC request id from `session/request_permission`
136−
137−### 3.4 Effect-first implementation style (required)
138−
139−1. Keep logic inside `CursorAdapterLive`.
140−2. Use Effect primitives:
141− - `Queue` + `Stream.fromQueue` for event fan-out
142− - `Ref` / `Ref.Synchronized` for session/process/request state
143− - scoped fibers for stdout/stderr read loops
144−3. Typed JSON decode at boundary:
145− - request/response envelopes
146− - `session/update` union schema
147− - permission-request schema
148−4. Keep adapter errors in typed error algebra with explicit mapping at process/protocol boundaries.
149−
150−---
151−
152−## 4) Canonical Event Mapping Plan (ACP -> ProviderRuntimeEvent)
153−
154−1. `session/update: agent_message_chunk`
155− - emit `message.delta` for assistant stream
156−2. prompt terminal response (`session/prompt` result `stopReason: end_turn`)
157− - emit `message.completed` + `turn.completed`
158−3. `session/update: agent_thought_chunk`
159− - initial mapping: emit thinking activity (or ignore if we keep current canonical surface minimal)
160−4. `session/update: tool_call`
161− - first-seen `toolCallId` emits `tool.started`
162− - subsequent `tool_call` for same ID treated as metadata update (no duplicate started event)
163−5. `session/update: tool_call_update`
164− - `in_progress`: optional progress activity
165− - `completed`: emit `tool.completed` with summarized `rawOutput` when present
166−6. `session/request_permission`
167− - emit `approval.requested` with mapped options
168− - when client decision sent, emit `approval.resolved`
169−7. protocol/process error
170− - emit `runtime.error`
171− - fail active turn/session as appropriate
172−
173−Synthetic IDs:
174−1. `turnId`: T3-generated UUID per `sendTurn`.
175−2. `itemId`:
176− - assistant stream: `${turnId}:assistant`
177− - tools: `${turnId}:${toolCallId}`
178−
179−---
180−
181−## 5) Approval, Resume, and Rollback Behavior
182−
183−### 5.1 Approvals
184−
185−1. Cursor ACP permission requests are externally controllable; implement full `respondToRequest` path in v1.
186−2. Decision mapping:
187− - allow once -> `allow-once`
188− - allow always -> `allow-always`
189− - reject -> `reject-once`
190−
191−### 5.2 Resume
192−
193−1. `session/load` is available and should be first-class for adapter restart/reconnect.
194−2. Must send required params: `sessionId`, `cwd`, `mcpServers`.
195−
196−### 5.3 Rollback / thread read
197−
198−1. ACP currently has no observed rollback API.
199−2. Plan for v1:
200− - `readThread`: adapter-maintained snapshot projection
201− - `rollbackThread`: explicit unsupported error
202−3. Product guard:
203− - disable checkpoint revert for Cursor threads in UI until rollback exists.
204−
205−---
206−
207−## 6) Required Contract and Runtime Changes
208−
209−### 6.1 Contracts
210−
211−1. Add `cursor` to `ProviderKind`.
212−2. Add Cursor provider start options (`providerOptions.cursor`), ACP-oriented:
213− - optional `binaryPath`
214− - optional auth/mode knobs if needed later
215−3. Extend model options for Cursor list and traits mapping.
216−4. Add schemas for ACP-native event union in Cursor adapter service file.
217−
218−### 6.2 Server orchestration and registry
219−
220−1. Register `CursorAdapter` in provider registry and server layer wiring.
221−2. Update provider-kind persistence decoding for `cursor`.
222−3. Fix `ProviderCommandReactor` precedence to honor explicit provider in turn-start command.
223−
224−### 6.3 Web
225−
226−1. Cursor in provider picker and model picker (already partially done).
227−2. Trait controls map to concrete Cursor model identifiers.
228−3. Surface unsupported rollback behavior in UX.
229−
230−---
231−
232−## 7) Implementation Phases
233−
234−### Phase A: ACP process and protocol skeleton
235−
236−1. Implement ACP process lifecycle in `CursorAdapterLive`.
237−2. Implement JSON-RPC request/response multiplexer.
238−3. Implement `initialize`/`authenticate`/`session/new|load` flow.
239−4. Wire `streamEvents` from ACP notifications.
240−
241−### Phase B: Runtime projection and approvals
242−
243−1. Map `session/update` variants to canonical runtime events.
244−2. Implement permission-request bridging to `respondToRequest`.
245−3. Implement dedupe for repeated `tool_call` on same `toolCallId`.
246−
247−### Phase C: Turn control and interruption
248−
249−1. Implement single in-flight prompt protection per session.
250−2. Implement interruption fallback (process restart + reload) because `session/cancel` unavailable.
251−3. Ensure clean state recovery on ACP process crash.
252−
253−### Phase D: Orchestration + UX polish
254−
255−1. Provider routing precedence fix.
256−2. Cursor-specific UX notes for unsupported rollback.
257−3. End-to-end smoke and event log validation.
258−
259−---
260−
261−## 8) Test Plan
262−
263−Follow project rule: backend external-service integrations tested via layered fakes, not by mocking core business logic.
264−
265−### 8.1 Unit tests (`CursorAdapter`)
266−
267−1. JSON-RPC envelope parsing:
268− - response matching by id
269− - server request handling (`session/request_permission`)
270− - notification decode (`session/update`)
271−2. Event projection:
272− - `agent_message_chunk` / `agent_thought_chunk`
273− - `tool_call` + `tool_call_update` dedupe/lifecycle
274− - permission request -> approval events
275−3. Error mapping:
276− - unknown session
277− - method-not-found (`session/cancel`)
278− - invalid params
279−
280−### 8.2 Provider service/routing tests
281−
282−1. Registry resolves `cursor`.
283−2. Session directory persistence reads/writes `cursor`.
284−3. ProviderService fan-out ordering with Cursor ACP events.
285−
286−### 8.3 Orchestration tests
287−
288−1. `thread.turn.start` with `provider: cursor` routes to Cursor adapter.
289−2. approval response command maps to ACP permission response.
290−3. checkpoint revert on Cursor thread returns controlled unsupported failure.
291−
292−### 8.4 Optional live smoke
293−
294−1. Env-gated ACP smoke:
295− - start session
296− - run prompt
297− - observe deltas + completion
298− - exercise permission request path with one tool call
299−
300−---
301−
302−## 9) Operational Notes
303−
304−1. Keep one in-flight turn per ACP session.
305−2. Keep per-session ACP process logs/NDJSON artifacts for debugging.
306−3. Treat `session/cancel` as unsupported until Cursor ships it; avoid relying on it.
307−4. Preserve resume metadata (`acpSessionId`) for crash recovery.
308−
309−---
310−
311−## 10) Open Questions
312−
313−1. Should we call `authenticate` always, or only after auth-required errors?
314−2. Should model selection be passed at `session/new` only, or can/should we support model switching mid-session if ACP adds API?
315−3. For interruption UX, do we expose “hard interrupt” semantics (process restart) explicitly?
316−
317−---
318−
319−## 11) Delivery Checklist
320−
321−1. Plan/documentation switched from headless `agent -p` to ACP `agent acp`.
322−2. Contracts updated (`ProviderKind`, Cursor options, model/trait mapping).
323−3. Cursor ACP adapter layer implemented and registered.
324−4. Provider precedence fixed in orchestration router.
325−5. Approval response path wired through ACP permission requests.
326−6. Tests added for protocol decode, projection, approval flow, and routing.
327−7. Lint + tests green.
package.json+1−2
@@ -35,7 +35,6 @@ · 34 unmodified lines
3535 "test": "turbo run test",
3636 "test:desktop-smoke": "turbo run smoke-test --filter=@t3tools/desktop",
3737 "fmt": "oxfmt",
38− "probe:cursor-acp": "node scripts/cursor-acp-probe.mjs",
3938 "build:contracts": "turbo run build --filter=@t3tools/contracts",
4039 "dist:desktop:artifact": "node scripts/build-desktop-artifact.ts",
4140 "dist:desktop:dmg": "node scripts/build-desktop-artifact.ts --platform mac --target dmg",
@@ -63,4 +62,4 @@ · 21 unmodified lines
6362 "apps/web/public"
6463 ]
6564 }
66−}
65+}
scripts/cursor-acp-probe.mjs+0−511
@@ -1,511 +0,0 @@
1−import { spawn } from "node:child_process";
2−import fs from "node:fs/promises";
3−import path from "node:path";
4−import readline from "node:readline";
5−
6−const DEFAULT_PROMPT_TIMEOUT_MS = 120_000;
7−const CANCEL_AFTER_MS = 1_500;
8−
9−function parseArgv(argv) {
10− const parsed = {
11− outputDir: "",
12− workspace: process.cwd(),
13− model: "",
14− permissionOption: "allow-once",
15− };
16−
17− for (let i = 0; i < argv.length; i += 1) {
18− const token = argv[i];
19− const next = argv[i + 1];
20− if ((token === "--output-dir" || token === "-o") && next) {
21− parsed.outputDir = next;
22− i += 1;
23− continue;
24− }
25− if ((token === "--workspace" || token === "-w") && next) {
26− parsed.workspace = path.resolve(next);
27− i += 1;
28− continue;
29− }
30− if ((token === "--model" || token === "-m") && next) {
31− parsed.model = next;
32− i += 1;
33− continue;
34− }
35− if (token === "--permission-option" && next) {
36− parsed.permissionOption = next;
37− i += 1;
38− }
39− }
40−
41− return parsed;
42−}
43−
44−function nowIso() {
45− return new Date().toISOString();
46−}
47−
48−function safeJsonParse(line) {
49− try {
50− return { ok: true, value: JSON.parse(line) };
51− } catch (error) {
52− return { ok: false, error };
53− }
54−}
55−
56−class AcpProbeClient {
57− #child;
58− #stdoutRl;
59− #stderrRl;
60− #nextId = 1;
61− #closed = false;
62− #pending = new Map();
63− #onMessage;
64− #onServerRequest;
65−
66− constructor({ onMessage, onServerRequest }) {
67− this.#onMessage = onMessage;
68− this.#onServerRequest = onServerRequest;
69−
70− this.#child = spawn("agent", ["acp"], {
71− stdio: ["pipe", "pipe", "pipe"],
72− env: {
73− ...process.env,
74− NO_COLOR: "1",
75− },
76− });
77−
78− this.#stdoutRl = readline.createInterface({ input: this.#child.stdout });
79− this.#stderrRl = readline.createInterface({ input: this.#child.stderr });
80−
81− this.#stdoutRl.on("line", (line) => this.#handleStdoutLine(line));
82− this.#stderrRl.on("line", (line) => {
83− this.#onMessage({
84− ts: nowIso(),
85− channel: "stderr",
86− line,
87− });
88− });
89−
90− this.#child.once("exit", (code, signal) => {
91− this.#closed = true;
92− const reason = `ACP process exited (code=${String(code)}, signal=${String(signal)})`;
93− for (const [id, pending] of this.#pending.entries()) {
94− this.#pending.delete(id);
95− pending.reject(new Error(reason));
96− }
97− this.#onMessage({
98− ts: nowIso(),
99− channel: "lifecycle",
100− event: "exit",
101− code,
102− signal,
103− });
104− });
105− }
106−
107− async close() {
108− if (this.#closed) return;
109− try {
110− this.#child.stdin.end();
111− } catch {
112− // ignored
113− }
114− this.#child.kill("SIGTERM");
115− await new Promise((resolve) => setTimeout(resolve, 100));
116− if (!this.#closed) {
117− this.#child.kill("SIGKILL");
118− }
119− }
120−
121− async send(method, params, { timeoutMs = DEFAULT_PROMPT_TIMEOUT_MS } = {}) {
122− if (this.#closed) {
123− throw new Error("Cannot send: ACP process is already closed.");
124− }
125− const id = this.#nextId;
126− this.#nextId += 1;
127−
128− const message = {
129− jsonrpc: "2.0",
130− id,
131− method,
132− params,
133− };
134−
135− this.#onMessage({
136− ts: nowIso(),
137− channel: "client->server",
138− message,
139− });
140− this.#child.stdin.write(`${JSON.stringify(message)}\n`);
141−
142− return new Promise((resolve, reject) => {
143− const timeout = setTimeout(() => {
144− this.#pending.delete(id);
145− reject(new Error(`Timed out waiting for response to '${method}' (id=${id}).`));
146− }, timeoutMs);
147−
148− this.#pending.set(id, {
149− resolve: (value) => {
150− clearTimeout(timeout);
151− resolve(value);
152− },
153− reject: (error) => {
154− clearTimeout(timeout);
155− reject(error);
156− },
157− });
158− });
159− }
160−
161− #respond(id, result) {
162− if (this.#closed) return;
163− const message = {
164− jsonrpc: "2.0",
165− id,
166− result,
167− };
168− this.#onMessage({
169− ts: nowIso(),
170− channel: "client->server",
171− message,
172− });
173− this.#child.stdin.write(`${JSON.stringify(message)}\n`);
174− }
175−
176− #handleStdoutLine(line) {
177− const parsed = safeJsonParse(line);
178− if (!parsed.ok) {
179− this.#onMessage({
180− ts: nowIso(),
181− channel: "stdout-non-json",
182− line,
183− });
184− return;
185− }
186−
187− const message = parsed.value;
188− this.#onMessage({
189− ts: nowIso(),
190− channel: "server->client",
191− message,
192− });
193−
194− if (
195− Object.prototype.hasOwnProperty.call(message, "id") &&
196− (Object.prototype.hasOwnProperty.call(message, "result") ||
197− Object.prototype.hasOwnProperty.call(message, "error")) &&
198− !Object.prototype.hasOwnProperty.call(message, "method")
199− ) {
200− const pending = this.#pending.get(message.id);
201− if (!pending) return;
202− this.#pending.delete(message.id);
203− if (Object.prototype.hasOwnProperty.call(message, "error")) {
204− pending.reject(message.error);
205− return;
206− }
207− pending.resolve(message.result);
208− return;
209− }
210−
211− if (
212− Object.prototype.hasOwnProperty.call(message, "id") &&
213− Object.prototype.hasOwnProperty.call(message, "method")
214− ) {
215− this.#onServerRequest?.(message, (result) => this.#respond(message.id, result));
216− }
217− }
218−}
219−
220−function summarizeTranscript(entries) {
221− const summary = {
222− counts: {
223− notificationsByMethod: {},
224− sessionUpdateByType: {},
225− serverRequestsByMethod: {},
226− permissionDecisions: {},
227− },
228− samples: {
229− initializeResult: null,
230− authenticateResult: null,
231− sessionNewResult: null,
232− sessionPromptResultByScenario: {},
233− sessionUpdateByType: {},
234− serverRequestByMethod: {},
235− },
236− stderr: [],
237− };
238−
239− for (const entry of entries) {
240− if (entry.channel === "stderr") {
241− summary.stderr.push(entry.line);
242− continue;
243− }
244− if (entry.channel !== "server->client") continue;
245−
246− const message = entry.message;
247− if (!message || typeof message !== "object") continue;
248−
249− if (typeof message.method === "string" && !Object.prototype.hasOwnProperty.call(message, "id")) {
250− summary.counts.notificationsByMethod[message.method] =
251− (summary.counts.notificationsByMethod[message.method] ?? 0) + 1;
252− if (message.method === "session/update") {
253− const updateType = message.params?.update?.sessionUpdate;
254− if (typeof updateType === "string") {
255− summary.counts.sessionUpdateByType[updateType] =
256− (summary.counts.sessionUpdateByType[updateType] ?? 0) + 1;
257− if (!summary.samples.sessionUpdateByType[updateType]) {
258− summary.samples.sessionUpdateByType[updateType] = message;
259− }
260− }
261− }
262− continue;
263− }
264−
265− if (typeof message.method === "string" && Object.prototype.hasOwnProperty.call(message, "id")) {
266− summary.counts.serverRequestsByMethod[message.method] =
267− (summary.counts.serverRequestsByMethod[message.method] ?? 0) + 1;
268− if (!summary.samples.serverRequestByMethod[message.method]) {
269− summary.samples.serverRequestByMethod[message.method] = message;
270− }
271− continue;
272− }
273− }
274−
275− for (const entry of entries) {
276− if (entry.channel !== "scenario-result") continue;
277− if (entry.scenario === "initialize" && !summary.samples.initializeResult) {
278− summary.samples.initializeResult = entry.result;
279− continue;
280− }
281− if (entry.scenario === "authenticate" && !summary.samples.authenticateResult) {
282− summary.samples.authenticateResult = entry.result;
283− continue;
284− }
285− if (entry.scenario === "session/new" && !summary.samples.sessionNewResult) {
286− summary.samples.sessionNewResult = entry.result;
287− continue;
288− }
289− if (entry.scenarioName) {
290− summary.samples.sessionPromptResultByScenario[entry.scenarioName] = entry.result;
291− }
292− }
293−
294− for (const entry of entries) {
295− if (entry.channel !== "permission-decision") continue;
296− const optionId = entry.optionId;
297− summary.counts.permissionDecisions[optionId] =
298− (summary.counts.permissionDecisions[optionId] ?? 0) + 1;
299− }
300−
301− return summary;
302−}
303−
304−async function run() {
305− const args = parseArgv(process.argv.slice(2));
306− const allowedPermissionOptions = new Set(["allow-once", "allow-always", "reject-once"]);
307− if (!allowedPermissionOptions.has(args.permissionOption)) {
308− throw new Error(
309− `Invalid --permission-option '${args.permissionOption}'. Expected one of: ${Array.from(allowedPermissionOptions).join(", ")}`,
310− );
311− }
312− const stamp = nowIso().replaceAll(":", "-");
313− const outputDir = args.outputDir
314− ? path.resolve(args.outputDir)
315− : path.join(process.cwd(), ".tmp", "acp-probe", stamp);
316− await fs.mkdir(outputDir, { recursive: true });
317−
318− const transcript = [];
319− const pushEntry = (entry) => transcript.push(entry);
320−
321− let activeScenarioName = null;
322−
323− const client = new AcpProbeClient({
324− onMessage: (entry) => {
325− if (
326− activeScenarioName &&
327− entry.channel === "server->client" &&
328− entry.message?.method === "session/update"
329− ) {
330− transcript.push({ ...entry, scenarioName: activeScenarioName });
331− return;
332− }
333− transcript.push(entry);
334− },
335− onServerRequest: (message, respond) => {
336− if (message.method === "session/request_permission") {
337− const defaultChoice = args.permissionOption;
338− pushEntry({
339− ts: nowIso(),
340− channel: "permission-decision",
341− requestId: message.id,
342− optionId: defaultChoice,
343− params: message.params,
344− });
345− respond({
346− outcome: {
347− outcome: "selected",
348− optionId: defaultChoice,
349− },
350− });
351− return;
352− }
353−
354− respond({
355− outcome: {
356− outcome: "selected",
357− optionId: "deny",
358− },
359− });
360− pushEntry({
361− ts: nowIso(),
362− channel: "server-request-unhandled",
363− method: message.method,
364− requestId: message.id,
365− params: message.params,
366− });
367− },
368− });
369−
370− try {
371− const initializeResult = await client.send("initialize", {
372− protocolVersion: 1,
373− clientCapabilities: {
374− fs: { readTextFile: false, writeTextFile: false },
375− terminal: false,
376− },
377− clientInfo: {
378− name: "t3-cursor-acp-probe",
379− version: "0.1.0",
380− },
381− });
382− pushEntry({
383− ts: nowIso(),
384− channel: "scenario-result",
385− scenario: "initialize",
386− result: initializeResult,
387− });
388−
389− const authenticateResult = await client.send("authenticate", {
390− methodId: "cursor_login",
391− });
392− pushEntry({
393− ts: nowIso(),
394− channel: "scenario-result",
395− scenario: "authenticate",
396− result: authenticateResult,
397− });
398−
399− const sessionParams = {
400− cwd: args.workspace,
401− mcpServers: [],
402− ...(args.model ? { model: args.model } : {}),
403− };
404− const sessionResult = await client.send("session/new", sessionParams);
405− pushEntry({
406− ts: nowIso(),
407− channel: "scenario-result",
408− scenario: "session/new",
409− result: sessionResult,
410− });
411−
412− const sessionId = sessionResult?.sessionId;
413− if (typeof sessionId !== "string" || sessionId.length === 0) {
414− throw new Error(`Missing sessionId from session/new response: ${JSON.stringify(sessionResult)}`);
415− }
416−
417− const scenarios = [
418− {
419− name: "hello",
420− prompt: "Say hello in one sentence.",
421− },
422− {
423− name: "tooling",
424− prompt:
425− "Use tools to run `pwd` and then `ls -1 | head -n 8`, and summarize what you found in one paragraph.",
426− },
427− {
428− name: "cancel",
429− prompt:
430− "Think for a while and draft a long detailed migration plan with at least 20 bullet points before answering.",
431− cancelAfterMs: CANCEL_AFTER_MS,
432− },
433− ];
434−
435− for (const scenario of scenarios) {
436− activeScenarioName = scenario.name;
437− const promptParams = {
438− sessionId,
439− prompt: [{ type: "text", text: scenario.prompt }],
440− };
441−
442− const promptPromise = client.send("session/prompt", promptParams, {
443− timeoutMs: DEFAULT_PROMPT_TIMEOUT_MS,
444− });
445−
446− if (scenario.cancelAfterMs) {
447− setTimeout(() => {
448− client
449− .send("session/cancel", { sessionId }, { timeoutMs: 15_000 })
450− .then((cancelResult) => {
451− pushEntry({
452− ts: nowIso(),
453− channel: "scenario-result",
454− scenario: "session/cancel",
455− scenarioName: scenario.name,
456− result: cancelResult,
457− });
458− })
459− .catch((error) => {
460− pushEntry({
461− ts: nowIso(),
462− channel: "scenario-error",
463− scenario: "session/cancel",
464− scenarioName: scenario.name,
465− error:
466− error instanceof Error
467− ? error.message
468− : typeof error === "string"
469− ? error
470− : JSON.stringify(error),
471− });
472− });
473− }, scenario.cancelAfterMs);
474− }
475−
476− const promptResult = await promptPromise;
477− pushEntry({
478− ts: nowIso(),
479− channel: "scenario-result",
480− scenario: "session/prompt",
481− scenarioName: scenario.name,
482− result: promptResult,
483− });
484− activeScenarioName = null;
485− await new Promise((resolve) => setTimeout(resolve, 500));
486− }
487− } finally {
488− await client.close();
489− }
490−
491− const summary = summarizeTranscript(transcript);
492− const transcriptPath = path.join(outputDir, "transcript.ndjson");
493− const summaryPath = path.join(outputDir, "summary.json");
494−
495− await fs.writeFile(
496− transcriptPath,
497− `${transcript.map((entry) => JSON.stringify(entry)).join("\n")}\n`,
498− "utf8",
499− );
500− await fs.writeFile(summaryPath, `${JSON.stringify(summary, null, 2)}\n`, "utf8");
501−
502− process.stdout.write(`ACP probe complete.\n`);
503− process.stdout.write(` outputDir: ${outputDir}\n`);
504− process.stdout.write(` transcript: ${transcriptPath}\n`);
505− process.stdout.write(` summary: ${summaryPath}\n`);
506−}
507−
508−run().catch((error) => {
509− process.stderr.write(`ACP probe failed: ${String(error)}\n`);
510− process.exitCode = 1;
511−});