From afad39d125a319e5fa22e15bbc8615b3a6267aa4 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 28 Feb 2026 18:26:13 -0800 Subject: [PATCH] Revise Cursor provider plan for ACP JSON-RPC integration - Switch plan from `agent -p` stream-json to `agent acp` JSON-RPC transport - Document ACP session, permission, event-mapping, and interrupt/recovery behavior - Update implementation phases, tests, and delivery checklist for ACP-first design --- .plans/18-cursor-agent-provider.md | 425 +++++++++++++++-------------- 1 file changed, 221 insertions(+), 204 deletions(-) diff --git a/.plans/18-cursor-agent-provider.md b/.plans/18-cursor-agent-provider.md index 03ea934380233b9037df9fb1e6e81d87635df23d..452592e68d6e7953d9efd07e464e7630e1eb8f7b 100644 --- a/.plans/18-cursor-agent-provider.md +++ b/.plans/18-cursor-agent-provider.md @@ -1,248 +1,260 @@ -# Plan: Cursor CLI (`agent`) Provider Integration +# Plan: Cursor ACP (`agent acp`) Provider Integration ## Goal -Add Cursor as a first-class provider in T3 Code using Cursor CLI stream JSON mode (`agent -p --output-format stream-json`), with robust session lifecycle handling and canonical `ProviderRuntimeEvent` projection. +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. --- -## 1) Exploration Findings (from live CLI runs) +## 1) Exploration Findings (from live ACP probes) -### 1.1 Core invocation shape +### 1.1 Core invocation and transport 1. Binary is `agent` on PATH (`2026.02.27-e7d2ef6` observed). -2. Non-interactive streaming mode: - - `agent -p --trust --output-format stream-json --stream-partial-output ...` -3. Session continuity: - - `agent create-chat` returns a chat UUID. - - `--resume ` and `--continue` preserve history. - - Stream `session_id` equals chat UUID. - -### 1.2 Stream JSON event families observed - -1. `system/init` -2. `user` -3. `thinking/delta`, `thinking/completed` -4. `assistant` (partial and final) -5. `tool_call/started`, `tool_call/completed` -6. `result/success` (`is_error: false` in all successful process exits) -7. Previously observed under load/rate-limit: - - `connection/reconnecting`, `connection/reconnected` - - `retry/starting`, `retry/resuming` - - followed by non-JSON terminal line `v: [resource_exhausted] Error` and process exit code `1` - -### 1.3 Tool call variants observed - -1. `shellToolCall` -2. `readToolCall` -3. `editToolCall` -4. `grepToolCall` -5. `globToolCall` - -Tool completion outcomes observed: - -1. `result.success` -2. `result.failure` (non-zero exit, stderr) -3. `result.rejected` (permission denied by Cursor approval layer) - -### 1.4 Critical protocol quirks - -1. `tool_call.call_id` contains embedded newline characters (must sanitize for IDs). -2. Startup failures are plain text, not JSON: - - invalid model -> plain error + exit `1` - - invalid API key -> plain warning + exit `1` -3. `result.subtype` can be `success` even when tool calls failed/rejected inside the turn. -4. Running multiple `agent` commands concurrently can race on `~/.cursor/cli-config.json` writes (`ENOENT rename ...cli-config.json.tmp` observed). - -Fixtures recorded in repo: - -1. `.tmp/cursor-observations/*.ndjson` -2. `.tmp/cursor-observations/startup-invalid-*.txt` +2. ACP server command is `agent acp`. +3. Transport is newline-delimited JSON-RPC 2.0 over stdio. +4. Messages: + - client -> server: requests and responses to server-initiated requests + - server -> client: responses, notifications (`session/update`), and server requests (`session/request_permission`) + +### 1.2 Handshake and session calls observed + +1. `initialize` returns: + - `protocolVersion` + - `agentCapabilities` (`loadSession`, `mcpCapabilities`, `promptCapabilities`) + - `authMethods` (includes `cursor_login`) +2. `authenticate { methodId: "cursor_login" }` returns `{}` when logged in. +3. `session/new` returns: + - `sessionId` + - `modes` (`agent`, `plan`, `ask`) +4. `session/load` works and requires `sessionId`, `cwd`, `mcpServers`. +5. `session/prompt` returns terminal response `{ stopReason: "end_turn" | "cancelled" }`. + +Important sequence note: +1. ACP currently allows `session/new` even without explicit `initialize`/`authenticate` when local auth already exists. +2. For adapter consistency and forward compatibility, we should still send `initialize` and `authenticate` during startup. + +### 1.3 `session/update` event families observed + +Observed `params.update.sessionUpdate` values: + +1. `available_commands_update` +2. `agent_thought_chunk` +3. `agent_message_chunk` +4. `tool_call` +5. `tool_call_update` + +Observed payload behavior: + +1. `agent_*_chunk` provides `content: { type: "text", text: string }`. +2. `tool_call` may be emitted multiple times for same `toolCallId`: + - initial generic form (`title: "Terminal"`, `rawInput: {}`) + - enriched form (`title: "\`pwd\`"`, `rawInput: { command: "pwd" }`) +3. `tool_call_update` statuses observed: + - `in_progress` + - `completed` +4. `tool_call_update` on completion may include `rawOutput`: + - terminal: `{ exitCode, stdout, stderr }` + - search/find: `{ totalFiles, truncated }` + +### 1.4 Permission flow observed + +1. ACP server sends `session/request_permission` (JSON-RPC request with `id`). +2. Request shape includes: + - `params.sessionId` + - `params.toolCall` + - `params.options` (`allow-once`, `allow-always`, `reject-once`) +3. Client must respond on same `id` with: + - `{ outcome: { outcome: "selected", optionId: "" } }` +4. Reject path still results in tool lifecycle completion events (`tool_call_update status: completed`), typically without `rawOutput`. + +### 1.5 Error and capability quirks + +1. `session/cancel` currently returns: + - JSON-RPC error `-32601` Method not found +2. Error shape examples: + - unknown auth method: `-32602` + - `session/load` missing/invalid params: `-32602` + - `session/prompt` unknown session: `-32603` with details +3. Parallel prompts on same session are effectively single-flight: + - second prompt can cause first to complete with `stopReason: "cancelled"`. +4. `session/new` accepts a `model` field (no explicit echo in response). + +Probe artifacts: +1. `.tmp/acp-probe/*/transcript.ndjson` +2. `.tmp/acp-probe/*/summary.json` +3. `scripts/cursor-acp-probe.mjs` --- ## 2) Integration Constraints for T3 -1. T3 requires adapter contract support for: +1. T3 adapter contract still requires: - `startSession`, `sendTurn`, `interruptTurn`, `respondToRequest`, `readThread`, `rollbackThread`, `stopSession`, `listSessions`, `hasSession`, `stopAll`, `streamEvents`. -2. Orchestration depends on canonical runtime events (`ProviderRuntimeEvent`) only. -3. Current `ProviderCommandReactor` ignores `thread.turn-start-requested.payload.provider` and prefers existing provider/session; this must be fixed for reliable provider selection (including Cursor). -4. Cursor CLI currently has no external approval callback API; approvals are internal to CLI behavior. +2. Orchestration consumes canonical `ProviderRuntimeEvent` only. +3. `ProviderCommandReactor` provider precedence fix remains required (respect explicit provider on turn start). +4. ACP now supports external permission decisions, so Cursor can participate in T3 approval UX via adapter-managed request/response plumbing. --- ## 3) Proposed Architecture -## 3.1 New server components +### 3.1 New server components -1. `apps/server/src/provider/Services/CursorAdapter.ts` (service contract/tag only for DI parity) -2. `apps/server/src/provider/Layers/CursorAdapter.ts` (single implementation unit; owns Cursor process lifecycle + stream parsing + runtime projection) -3. No separate `cursorCliManager.ts` abstraction in v1. +1. `apps/server/src/provider/Services/CursorAdapter.ts` (service contract/tag + ACP event schemas). +2. `apps/server/src/provider/Layers/CursorAdapter.ts` (single implementation unit; owns ACP process lifecycle, JSON-RPC routing, runtime projection). +3. No manager indirection; keep logic in layer implementation. ### 3.2 Session model -1. T3 `ProviderSessionId` = synthetic UUID managed by adapter (stable for T3 APIs). -2. Cursor chat UUID stored as: - - `ProviderSession.resumeCursor = { chatId: string }` - - `ProviderSession.threadId = ProviderThreadId(chatId)` for adapter contract compatibility. -3. Adapter keeps runtime map: - - `sessionId -> { chatId, cwd, model, activeChildProcess?, activeTurnId?, turnLog }` +1. One long-lived ACP child process per T3 Cursor provider session. +2. Track: + - `providerSessionId` (T3 synthetic ID) + - `acpSessionId` (from `session/new` or restored via `session/load`) + - `cwd`, `model`, in-flight turn state + - pending permission requests by JSON-RPC request id +3. Resume support: + - persist `acpSessionId` in provider resume metadata and call `session/load` on reattach. ### 3.3 Command strategy 1. `startSession`: - - call `agent create-chat` unless `resumeCursor.chatId` exists. - - validate provider is `cursor` when provided. + - spawn `agent acp` + - `initialize` + - `authenticate(cursor_login)` (best-effort, typed failure handling) + - `session/new` or `session/load` 2. `sendTurn`: - - spawn `agent -p --trust --output-format stream-json --stream-partial-output --resume `. - - add `--model` when provided. - - map runtime mode: - - `approvalPolicy = never` -> include `--force` - - otherwise omit `--force` (commands may be rejected by CLI). - - use `--workspace ` when available. + - send `session/prompt { sessionId, prompt: [...] }` + - consume streaming `session/update` notifications until terminal prompt response 3. `interruptTurn`: - - terminate active child process for session (`SIGINT` then hard kill fallback). + - no native `session/cancel` today; implement fallback: + - terminate ACP process + restart + `session/load` for subsequent turns + - mark in-flight turn as interrupted/failed in canonical events +4. `respondToRequest`: + - map T3 approval decision -> ACP `optionId` + - reply to exact JSON-RPC request id from `session/request_permission` ### 3.4 Effect-first implementation style (required) -1. Keep adapter logic inside `CursorAdapterLive` layer constructor (no manager indirection). -2. Use Effect process primitives first: - - `ChildProcessSpawner` + `ChildProcess.make` from `effect/unstable/process` for `agent` execution. -3. Use Effect concurrency/state primitives: - - `Queue` for adapter event queue, `Stream.fromQueue` for `streamEvents` - - `Ref` / `Ref.Synchronized` for session maps and active-turn process handles - - `Effect.scoped` + `Effect.forkScoped` for worker fibers and cleanup -4. Use Effect stream parsing path for stdout/stderr: - - decode bytes -> line buffer -> JSON parse -> typed projection - - keep non-JSON lines on a fallback branch that emits `runtime.error` -5. Keep errors in typed adapter error algebra (`ProviderAdapter*Error`) via `Effect.mapError` boundaries, not ad-hoc exceptions. +1. Keep logic inside `CursorAdapterLive`. +2. Use Effect primitives: + - `Queue` + `Stream.fromQueue` for event fan-out + - `Ref` / `Ref.Synchronized` for session/process/request state + - scoped fibers for stdout/stderr read loops +3. Typed JSON decode at boundary: + - request/response envelopes + - `session/update` union schema + - permission-request schema +4. Keep adapter errors in typed error algebra with explicit mapping at process/protocol boundaries. --- -## 4) Canonical Event Mapping Plan +## 4) Canonical Event Mapping Plan (ACP -> ProviderRuntimeEvent) -For each parsed NDJSON line, emit zero or more `ProviderRuntimeEvent`s: - -1. `system/init` - - emit `session.started` (once per adapter session lifecycle) - - emit `thread.started` if thread not yet emitted -2. `assistant` partial chunks - - emit `message.delta` with stable per-turn synthetic `itemId` -3. final `assistant` message - - emit `message.completed` -4. `tool_call/started` - - emit `tool.started` - - map kind: - - shell -> `command` - - edit -> `file-change` - - read/grep/glob -> `other` -5. `tool_call/completed` - - emit `tool.completed` - - include summary detail from nested payload (`command`, `path`, stderr fragment, rejection reason) -6. `thinking/*` - - initial version: ignore for canonical events (optional later: map to `tool.*` “Thinking”) -7. `result/success` - - emit `turn.completed` with `status: completed` unless unrecoverable parse/process error already set failure -8. non-JSON stdout/stderr protocol line +1. `session/update: agent_message_chunk` + - emit `message.delta` for assistant stream +2. prompt terminal response (`session/prompt` result `stopReason: end_turn`) + - emit `message.completed` + `turn.completed` +3. `session/update: agent_thought_chunk` + - initial mapping: emit thinking activity (or ignore if we keep current canonical surface minimal) +4. `session/update: tool_call` + - first-seen `toolCallId` emits `tool.started` + - subsequent `tool_call` for same ID treated as metadata update (no duplicate started event) +5. `session/update: tool_call_update` + - `in_progress`: optional progress activity + - `completed`: emit `tool.completed` with summarized `rawOutput` when present +6. `session/request_permission` + - emit `approval.requested` with mapped options + - when client decision sent, emit `approval.resolved` +7. protocol/process error - emit `runtime.error` - - emit `turn.completed` with `status: failed` -9. `connection/*` + `retry/*` (when present) - - emit `tool.started/completed` with `toolKind: other`, title like “Connection retry” - - if final failure follows, also emit `runtime.error` + - fail active turn/session as appropriate Synthetic IDs: - -1. `turnId`: adapter-generated UUID per `sendTurn`. -2. `itemId`: `${turnId}:assistant` for assistant stream; `${turnId}:${sanitizedCallId}` for tool calls. -3. `call_id` sanitization: replace whitespace (including newline) with `_`. +1. `turnId`: T3-generated UUID per `sendTurn`. +2. `itemId`: + - assistant stream: `${turnId}:assistant` + - tools: `${turnId}:${toolCallId}` --- -## 5) Approval and Checkpoint Behavior (explicit limitations) +## 5) Approval, Resume, and Rollback Behavior ### 5.1 Approvals -1. Cursor CLI approval is internal; adapter cannot accept/reject mid-turn via `respondToRequest`. -2. Plan for v1: - - `respondToRequest` returns `ProviderAdapterRequestError` (“Cursor CLI does not expose external approval response API”). - - approval-required runtime uses no `--force`, so dangerous commands become `tool_call.result.rejected`. -3. Future: - - if Cursor exposes approval RPC/hooks, implement `approval.requested` + `approval.resolved` fully. +1. Cursor ACP permission requests are externally controllable; implement full `respondToRequest` path in v1. +2. Decision mapping: + - allow once -> `allow-once` + - allow always -> `allow-always` + - reject -> `reject-once` + +### 5.2 Resume + +1. `session/load` is available and should be first-class for adapter restart/reconnect. +2. Must send required params: `sessionId`, `cwd`, `mcpServers`. -### 5.2 Rollback / thread read +### 5.3 Rollback / thread read -1. Cursor CLI has no observed equivalent to `thread/read` or `thread/rollback`. +1. ACP currently has no observed rollback API. 2. Plan for v1: - - `readThread` returns adapter-maintained in-memory turn snapshot. - - `rollbackThread` returns `ProviderAdapterRequestError` unsupported. + - `readThread`: adapter-maintained snapshot projection + - `rollbackThread`: explicit unsupported error 3. Product guard: - - disable checkpoint revert for Cursor threads until rollback is implemented. + - disable checkpoint revert for Cursor threads in UI until rollback exists. --- ## 6) Required Contract and Runtime Changes -## 6.1 Contracts +### 6.1 Contracts -1. `packages/contracts/src/orchestration.ts` - - add `cursor` to `ProviderKind`. -2. `packages/contracts/src/provider.ts` - - add `CursorProviderStartOptions` under `providerOptions.cursor`: - - `binaryPath?` - - `apiKey?` (optional; default login auth) - - `trust?` (default true for headless) -3. `packages/contracts/src/model.ts` - - add `cursor` provider model options baseline. - - initial set should mirror observed supported IDs that are stable enough for UX. -4. Update related contract tests. +1. Add `cursor` to `ProviderKind`. +2. Add Cursor provider start options (`providerOptions.cursor`), ACP-oriented: + - optional `binaryPath` + - optional auth/mode knobs if needed later +3. Extend model options for Cursor list and traits mapping. +4. Add schemas for ACP-native event union in Cursor adapter service file. -## 6.2 Server orchestration and registry +### 6.2 Server orchestration and registry -1. Add `CursorAdapter` to provider registry and server layer wiring. -2. Update provider-kind decoding in: - - `apps/server/src/provider/Layers/ProviderSessionDirectory.ts` - - persistence schemas that currently literal-match provider kinds. -3. Fix provider selection in `ProviderCommandReactor`: - - honor `thread.turn-start-requested.payload.provider` as highest precedence. - - fallback to existing session provider, then default. +1. Register `CursorAdapter` in provider registry and server layer wiring. +2. Update provider-kind persistence decoding for `cursor`. +3. Fix `ProviderCommandReactor` precedence to honor explicit provider in turn-start command. -## 6.3 Web +### 6.3 Web -1. Enable Cursor in provider selector (`apps/web/src/session-logic.ts`). -2. Add Cursor label/icon treatment. -3. Add optional Cursor settings fields if using per-user overrides (binary path/api key). -4. Ensure thread/provider display and legacy mapping code handles `cursor`. +1. Cursor in provider picker and model picker (already partially done). +2. Trait controls map to concrete Cursor model identifiers. +3. Surface unsupported rollback behavior in UX. --- ## 7) Implementation Phases -### Phase A: Protocol-safe adapter skeleton +### Phase A: ACP process and protocol skeleton -1. Implement `CursorAdapter` service/layer with all runtime logic in `CursorAdapterLive` (no CLI manager), plus session map + event queue. -2. Implement robust NDJSON parser with: - - line buffering - - tolerant non-JSON handling - - explicit process-exit mapping. -3. Wire into registry and server layers. +1. Implement ACP process lifecycle in `CursorAdapterLive`. +2. Implement JSON-RPC request/response multiplexer. +3. Implement `initialize`/`authenticate`/`session/new|load` flow. +4. Wire `streamEvents` from ACP notifications. -### Phase B: Event projection completeness +### Phase B: Runtime projection and approvals -1. Map assistant/tool/result events to canonical runtime events. -2. Add synthetic ID strategy and item lifecycle consistency. -3. Add runtime error and retry handling. +1. Map `session/update` variants to canonical runtime events. +2. Implement permission-request bridging to `respondToRequest`. +3. Implement dedupe for repeated `tool_call` on same `toolCallId`. -### Phase C: Provider selection and UX +### Phase C: Turn control and interruption -1. Fix `ProviderCommandReactor` provider precedence. -2. Enable Cursor provider in web UI. -3. Add Cursor model defaults/options. +1. Implement single in-flight prompt protection per session. +2. Implement interruption fallback (process restart + reload) because `session/cancel` unavailable. +3. Ensure clean state recovery on ACP process crash. -### Phase D: Safeguards and unsupported surfaces +### Phase D: Orchestration + UX polish -1. Explicit unsupported errors for `respondToRequest` and `rollbackThread` (v1). -2. Guard checkpoint-revert path for Cursor threads. -3. Document limitations in UI/help text. +1. Provider routing precedence fix. +2. Cursor-specific UX notes for unsupported rollback. +3. End-to-end smoke and event log validation. --- @@ -252,59 +264,64 @@ Follow project rule: backend external-service integrations tested via layered fa ### 8.1 Unit tests (`CursorAdapter`) -1. stream JSON parse and mapping: - - assistant partial/final - - each tool type - - shell success/failure/rejected - - non-JSON line failure -2. process lifecycle: - - start/send/interrupt/stop - - stale session errors -3. ID sanitization: - - `call_id` with newline. +1. JSON-RPC envelope parsing: + - response matching by id + - server request handling (`session/request_permission`) + - notification decode (`session/update`) +2. Event projection: + - `agent_message_chunk` / `agent_thought_chunk` + - `tool_call` + `tool_call_update` dedupe/lifecycle + - permission request -> approval events +3. Error mapping: + - unknown session + - method-not-found (`session/cancel`) + - invalid params ### 8.2 Provider service/routing tests 1. Registry resolves `cursor`. -2. Session directory persistence reads/writes `cursor` provider. -3. ProviderService fan-out/order with Cursor events. +2. Session directory persistence reads/writes `cursor`. +3. ProviderService fan-out ordering with Cursor ACP events. ### 8.3 Orchestration tests 1. `thread.turn.start` with `provider: cursor` routes to Cursor adapter. -2. checkpoint revert on Cursor thread returns controlled failure activity. -3. approval response command on Cursor session surfaces “unsupported approval API”. +2. approval response command maps to ACP permission response. +3. checkpoint revert on Cursor thread returns controlled unsupported failure. -### 8.4 Smoke/integration harness +### 8.4 Optional live smoke -1. Optional local smoke behind env flag (`CURSOR_SMOKE=1`) to run real `agent`: - - create session - - send simple turn - - verify message delta/completion + turn completion. +1. Env-gated ACP smoke: + - start session + - run prompt + - observe deltas + completion + - exercise permission request path with one tool call --- ## 9) Operational Notes -1. Do not run concurrent `agent` processes per workspace by default; serialize per adapter session to avoid config races. -2. Keep native event NDJSON logs for Cursor similar to Codex adapter logs. -3. Treat startup plain-text failures as process-level errors and surface full message. +1. Keep one in-flight turn per ACP session. +2. Keep per-session ACP process logs/NDJSON artifacts for debugging. +3. Treat `session/cancel` as unsupported until Cursor ships it; avoid relying on it. +4. Preserve resume metadata (`acpSessionId`) for crash recovery. --- ## 10) Open Questions -1. Should Cursor provider expose full model list dynamically (`agent --list-models`) or use curated static list in contracts? -2. Should T3 keep strict approval UX parity with Codex, or accept Cursor’s current non-interactive approval limitation in v1? -3. Is checkpoint revert required for initial Cursor release, or can it ship as explicitly unsupported? +1. Should we call `authenticate` always, or only after auth-required errors? +2. Should model selection be passed at `session/new` only, or can/should we support model switching mid-session if ACP adds API? +3. For interruption UX, do we expose “hard interrupt” semantics (process restart) explicitly? --- ## 11) Delivery Checklist -1. Contracts updated (`ProviderKind`, provider start options, models). -2. Cursor adapter + layer implemented and registered. -3. Provider selection precedence fixed in `ProviderCommandReactor`. -4. Cursor enabled in web provider picker. -5. Tests added for adapter mapping, routing, and orchestration behavior. -6. Lint + test suite green. +1. Plan/documentation switched from headless `agent -p` to ACP `agent acp`. +2. Contracts updated (`ProviderKind`, Cursor options, model/trait mapping). +3. Cursor ACP adapter layer implemented and registered. +4. Provider precedence fixed in orchestration router. +5. Approval response path wired through ACP permission requests. +6. Tests added for protocol decode, projection, approval flow, and routing. +7. Lint + tests green. -- 2.54.0