| author | |
| committer | |
| log | 2435a9cd58d697332f304833770febec0ff01262 |
| tree | 950d951fd323faa7cac25cf8a9b84862dccf19e3 |
| parent | 78d53062d1669c911cd938dd3a32faceafd6a57c |
Co-authored-by: codex <codex@users.noreply.github.com>3 files changed, 1 insertions(+), 840 deletions(-)
.plans/18-cursor-agent-provider.md deleted-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 @@ | ... | @@ -35,7 +35,6 @@ |
| 35 | "test": "turbo run test", | 35 | "test": "turbo run test", |
| 36 | "test:desktop-smoke": "turbo run smoke-test --filter=@t3tools/desktop", | 36 | "test:desktop-smoke": "turbo run smoke-test --filter=@t3tools/desktop", |
| 37 | "fmt": "oxfmt", | 37 | "fmt": "oxfmt", |
| 38 | "probe:cursor-acp": "node scripts/cursor-acp-probe.mjs", | ||
| 39 | "build:contracts": "turbo run build --filter=@t3tools/contracts", | 38 | "build:contracts": "turbo run build --filter=@t3tools/contracts", |
| 40 | "dist:desktop:artifact": "node scripts/build-desktop-artifact.ts", | 39 | "dist:desktop:artifact": "node scripts/build-desktop-artifact.ts", |
| 41 | "dist:desktop:dmg": "node scripts/build-desktop-artifact.ts --platform mac --target dmg", | 40 | "dist:desktop:dmg": "node scripts/build-desktop-artifact.ts --platform mac --target dmg", |
| ... | @@ -63,4 +62,4 @@ | ... | @@ -63,4 +62,4 @@ |
| 63 | "apps/web/public" | 62 | "apps/web/public" |
| 64 | ] | 63 | ] |
| 65 | } | 64 | } |
| 66 | } | ||
| \ No newline at end of file | |||
| 65 | } | ||
scripts/cursor-acp-probe.mjs deleted-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 | }); | ||