| author | |
| committer | |
| log | 04a931fc7992152b388faef5cda28e64a0887303 |
| tree | 02088fdb26458b26615e651fd320c825f460c81f |
| parent | 75354bbd962d9fc38a1ba40ee78520822b349016 |
- define `CursorAdapter` service contract and Cursor stream-json schema types
- add `CursorCliStreamEvent` decoding tests for system/thinking/tool/result/retry events
- add implementation plan for Cursor provider integration
Co-authored-by: codex <codex@users.noreply.github.com>3 files changed, 656 insertions(+), 0 deletions(-)
.plans/18-cursor-agent-provider.md created+310| ... | @@ -0,0 +1,310 @@ | ||
| 1 | # Plan: Cursor CLI (`agent`) Provider Integration | ||
| 2 | |||
| 3 | ## Goal | ||
| 4 | |||
| 5 | 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. | ||
| 6 | |||
| 7 | --- | ||
| 8 | |||
| 9 | ## 1) Exploration Findings (from live CLI runs) | ||
| 10 | |||
| 11 | ### 1.1 Core invocation shape | ||
| 12 | |||
| 13 | 1. Binary is `agent` on PATH (`2026.02.27-e7d2ef6` observed). | ||
| 14 | 2. Non-interactive streaming mode: | ||
| 15 | - `agent -p --trust --output-format stream-json --stream-partial-output ...` | ||
| 16 | 3. Session continuity: | ||
| 17 | - `agent create-chat` returns a chat UUID. | ||
| 18 | - `--resume <chatId>` and `--continue` preserve history. | ||
| 19 | - Stream `session_id` equals chat UUID. | ||
| 20 | |||
| 21 | ### 1.2 Stream JSON event families observed | ||
| 22 | |||
| 23 | 1. `system/init` | ||
| 24 | 2. `user` | ||
| 25 | 3. `thinking/delta`, `thinking/completed` | ||
| 26 | 4. `assistant` (partial and final) | ||
| 27 | 5. `tool_call/started`, `tool_call/completed` | ||
| 28 | 6. `result/success` (`is_error: false` in all successful process exits) | ||
| 29 | 7. Previously observed under load/rate-limit: | ||
| 30 | - `connection/reconnecting`, `connection/reconnected` | ||
| 31 | - `retry/starting`, `retry/resuming` | ||
| 32 | - followed by non-JSON terminal line `v: [resource_exhausted] Error` and process exit code `1` | ||
| 33 | |||
| 34 | ### 1.3 Tool call variants observed | ||
| 35 | |||
| 36 | 1. `shellToolCall` | ||
| 37 | 2. `readToolCall` | ||
| 38 | 3. `editToolCall` | ||
| 39 | 4. `grepToolCall` | ||
| 40 | 5. `globToolCall` | ||
| 41 | |||
| 42 | Tool completion outcomes observed: | ||
| 43 | |||
| 44 | 1. `result.success` | ||
| 45 | 2. `result.failure` (non-zero exit, stderr) | ||
| 46 | 3. `result.rejected` (permission denied by Cursor approval layer) | ||
| 47 | |||
| 48 | ### 1.4 Critical protocol quirks | ||
| 49 | |||
| 50 | 1. `tool_call.call_id` contains embedded newline characters (must sanitize for IDs). | ||
| 51 | 2. Startup failures are plain text, not JSON: | ||
| 52 | - invalid model -> plain error + exit `1` | ||
| 53 | - invalid API key -> plain warning + exit `1` | ||
| 54 | 3. `result.subtype` can be `success` even when tool calls failed/rejected inside the turn. | ||
| 55 | 4. Running multiple `agent` commands concurrently can race on `~/.cursor/cli-config.json` writes (`ENOENT rename ...cli-config.json.tmp` observed). | ||
| 56 | |||
| 57 | Fixtures recorded in repo: | ||
| 58 | |||
| 59 | 1. `.tmp/cursor-observations/*.ndjson` | ||
| 60 | 2. `.tmp/cursor-observations/startup-invalid-*.txt` | ||
| 61 | |||
| 62 | --- | ||
| 63 | |||
| 64 | ## 2) Integration Constraints for T3 | ||
| 65 | |||
| 66 | 1. T3 requires adapter contract support for: | ||
| 67 | - `startSession`, `sendTurn`, `interruptTurn`, `respondToRequest`, `readThread`, `rollbackThread`, `stopSession`, `listSessions`, `hasSession`, `stopAll`, `streamEvents`. | ||
| 68 | 2. Orchestration depends on canonical runtime events (`ProviderRuntimeEvent`) only. | ||
| 69 | 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). | ||
| 70 | 4. Cursor CLI currently has no external approval callback API; approvals are internal to CLI behavior. | ||
| 71 | |||
| 72 | --- | ||
| 73 | |||
| 74 | ## 3) Proposed Architecture | ||
| 75 | |||
| 76 | ## 3.1 New server components | ||
| 77 | |||
| 78 | 1. `apps/server/src/provider/Services/CursorAdapter.ts` (service contract/tag only for DI parity) | ||
| 79 | 2. `apps/server/src/provider/Layers/CursorAdapter.ts` (single implementation unit; owns Cursor process lifecycle + stream parsing + runtime projection) | ||
| 80 | 3. No separate `cursorCliManager.ts` abstraction in v1. | ||
| 81 | |||
| 82 | ### 3.2 Session model | ||
| 83 | |||
| 84 | 1. T3 `ProviderSessionId` = synthetic UUID managed by adapter (stable for T3 APIs). | ||
| 85 | 2. Cursor chat UUID stored as: | ||
| 86 | - `ProviderSession.resumeCursor = { chatId: string }` | ||
| 87 | - `ProviderSession.threadId = ProviderThreadId(chatId)` for adapter contract compatibility. | ||
| 88 | 3. Adapter keeps runtime map: | ||
| 89 | - `sessionId -> { chatId, cwd, model, activeChildProcess?, activeTurnId?, turnLog }` | ||
| 90 | |||
| 91 | ### 3.3 Command strategy | ||
| 92 | |||
| 93 | 1. `startSession`: | ||
| 94 | - call `agent create-chat` unless `resumeCursor.chatId` exists. | ||
| 95 | - validate provider is `cursor` when provided. | ||
| 96 | 2. `sendTurn`: | ||
| 97 | - spawn `agent -p --trust --output-format stream-json --stream-partial-output --resume <chatId>`. | ||
| 98 | - add `--model` when provided. | ||
| 99 | - map runtime mode: | ||
| 100 | - `approvalPolicy = never` -> include `--force` | ||
| 101 | - otherwise omit `--force` (commands may be rejected by CLI). | ||
| 102 | - use `--workspace <cwd>` when available. | ||
| 103 | 3. `interruptTurn`: | ||
| 104 | - terminate active child process for session (`SIGINT` then hard kill fallback). | ||
| 105 | |||
| 106 | ### 3.4 Effect-first implementation style (required) | ||
| 107 | |||
| 108 | 1. Keep adapter logic inside `CursorAdapterLive` layer constructor (no manager indirection). | ||
| 109 | 2. Use Effect process primitives first: | ||
| 110 | - `ChildProcessSpawner` + `ChildProcess.make` from `effect/unstable/process` for `agent` execution. | ||
| 111 | 3. Use Effect concurrency/state primitives: | ||
| 112 | - `Queue` for adapter event queue, `Stream.fromQueue` for `streamEvents` | ||
| 113 | - `Ref` / `Ref.Synchronized` for session maps and active-turn process handles | ||
| 114 | - `Effect.scoped` + `Effect.forkScoped` for worker fibers and cleanup | ||
| 115 | 4. Use Effect stream parsing path for stdout/stderr: | ||
| 116 | - decode bytes -> line buffer -> JSON parse -> typed projection | ||
| 117 | - keep non-JSON lines on a fallback branch that emits `runtime.error` | ||
| 118 | 5. Keep errors in typed adapter error algebra (`ProviderAdapter*Error`) via `Effect.mapError` boundaries, not ad-hoc exceptions. | ||
| 119 | |||
| 120 | --- | ||
| 121 | |||
| 122 | ## 4) Canonical Event Mapping Plan | ||
| 123 | |||
| 124 | For each parsed NDJSON line, emit zero or more `ProviderRuntimeEvent`s: | ||
| 125 | |||
| 126 | 1. `system/init` | ||
| 127 | - emit `session.started` (once per adapter session lifecycle) | ||
| 128 | - emit `thread.started` if thread not yet emitted | ||
| 129 | 2. `assistant` partial chunks | ||
| 130 | - emit `message.delta` with stable per-turn synthetic `itemId` | ||
| 131 | 3. final `assistant` message | ||
| 132 | - emit `message.completed` | ||
| 133 | 4. `tool_call/started` | ||
| 134 | - emit `tool.started` | ||
| 135 | - map kind: | ||
| 136 | - shell -> `command` | ||
| 137 | - edit -> `file-change` | ||
| 138 | - read/grep/glob -> `other` | ||
| 139 | 5. `tool_call/completed` | ||
| 140 | - emit `tool.completed` | ||
| 141 | - include summary detail from nested payload (`command`, `path`, stderr fragment, rejection reason) | ||
| 142 | 6. `thinking/*` | ||
| 143 | - initial version: ignore for canonical events (optional later: map to `tool.*` “Thinking”) | ||
| 144 | 7. `result/success` | ||
| 145 | - emit `turn.completed` with `status: completed` unless unrecoverable parse/process error already set failure | ||
| 146 | 8. non-JSON stdout/stderr protocol line | ||
| 147 | - emit `runtime.error` | ||
| 148 | - emit `turn.completed` with `status: failed` | ||
| 149 | 9. `connection/*` + `retry/*` (when present) | ||
| 150 | - emit `tool.started/completed` with `toolKind: other`, title like “Connection retry” | ||
| 151 | - if final failure follows, also emit `runtime.error` | ||
| 152 | |||
| 153 | Synthetic IDs: | ||
| 154 | |||
| 155 | 1. `turnId`: adapter-generated UUID per `sendTurn`. | ||
| 156 | 2. `itemId`: `${turnId}:assistant` for assistant stream; `${turnId}:${sanitizedCallId}` for tool calls. | ||
| 157 | 3. `call_id` sanitization: replace whitespace (including newline) with `_`. | ||
| 158 | |||
| 159 | --- | ||
| 160 | |||
| 161 | ## 5) Approval and Checkpoint Behavior (explicit limitations) | ||
| 162 | |||
| 163 | ### 5.1 Approvals | ||
| 164 | |||
| 165 | 1. Cursor CLI approval is internal; adapter cannot accept/reject mid-turn via `respondToRequest`. | ||
| 166 | 2. Plan for v1: | ||
| 167 | - `respondToRequest` returns `ProviderAdapterRequestError` (“Cursor CLI does not expose external approval response API”). | ||
| 168 | - approval-required runtime uses no `--force`, so dangerous commands become `tool_call.result.rejected`. | ||
| 169 | 3. Future: | ||
| 170 | - if Cursor exposes approval RPC/hooks, implement `approval.requested` + `approval.resolved` fully. | ||
| 171 | |||
| 172 | ### 5.2 Rollback / thread read | ||
| 173 | |||
| 174 | 1. Cursor CLI has no observed equivalent to `thread/read` or `thread/rollback`. | ||
| 175 | 2. Plan for v1: | ||
| 176 | - `readThread` returns adapter-maintained in-memory turn snapshot. | ||
| 177 | - `rollbackThread` returns `ProviderAdapterRequestError` unsupported. | ||
| 178 | 3. Product guard: | ||
| 179 | - disable checkpoint revert for Cursor threads until rollback is implemented. | ||
| 180 | |||
| 181 | --- | ||
| 182 | |||
| 183 | ## 6) Required Contract and Runtime Changes | ||
| 184 | |||
| 185 | ## 6.1 Contracts | ||
| 186 | |||
| 187 | 1. `packages/contracts/src/orchestration.ts` | ||
| 188 | - add `cursor` to `ProviderKind`. | ||
| 189 | 2. `packages/contracts/src/provider.ts` | ||
| 190 | - add `CursorProviderStartOptions` under `providerOptions.cursor`: | ||
| 191 | - `binaryPath?` | ||
| 192 | - `apiKey?` (optional; default login auth) | ||
| 193 | - `trust?` (default true for headless) | ||
| 194 | 3. `packages/contracts/src/model.ts` | ||
| 195 | - add `cursor` provider model options baseline. | ||
| 196 | - initial set should mirror observed supported IDs that are stable enough for UX. | ||
| 197 | 4. Update related contract tests. | ||
| 198 | |||
| 199 | ## 6.2 Server orchestration and registry | ||
| 200 | |||
| 201 | 1. Add `CursorAdapter` to provider registry and server layer wiring. | ||
| 202 | 2. Update provider-kind decoding in: | ||
| 203 | - `apps/server/src/provider/Layers/ProviderSessionDirectory.ts` | ||
| 204 | - persistence schemas that currently literal-match provider kinds. | ||
| 205 | 3. Fix provider selection in `ProviderCommandReactor`: | ||
| 206 | - honor `thread.turn-start-requested.payload.provider` as highest precedence. | ||
| 207 | - fallback to existing session provider, then default. | ||
| 208 | |||
| 209 | ## 6.3 Web | ||
| 210 | |||
| 211 | 1. Enable Cursor in provider selector (`apps/web/src/session-logic.ts`). | ||
| 212 | 2. Add Cursor label/icon treatment. | ||
| 213 | 3. Add optional Cursor settings fields if using per-user overrides (binary path/api key). | ||
| 214 | 4. Ensure thread/provider display and legacy mapping code handles `cursor`. | ||
| 215 | |||
| 216 | --- | ||
| 217 | |||
| 218 | ## 7) Implementation Phases | ||
| 219 | |||
| 220 | ### Phase A: Protocol-safe adapter skeleton | ||
| 221 | |||
| 222 | 1. Implement `CursorAdapter` service/layer with all runtime logic in `CursorAdapterLive` (no CLI manager), plus session map + event queue. | ||
| 223 | 2. Implement robust NDJSON parser with: | ||
| 224 | - line buffering | ||
| 225 | - tolerant non-JSON handling | ||
| 226 | - explicit process-exit mapping. | ||
| 227 | 3. Wire into registry and server layers. | ||
| 228 | |||
| 229 | ### Phase B: Event projection completeness | ||
| 230 | |||
| 231 | 1. Map assistant/tool/result events to canonical runtime events. | ||
| 232 | 2. Add synthetic ID strategy and item lifecycle consistency. | ||
| 233 | 3. Add runtime error and retry handling. | ||
| 234 | |||
| 235 | ### Phase C: Provider selection and UX | ||
| 236 | |||
| 237 | 1. Fix `ProviderCommandReactor` provider precedence. | ||
| 238 | 2. Enable Cursor provider in web UI. | ||
| 239 | 3. Add Cursor model defaults/options. | ||
| 240 | |||
| 241 | ### Phase D: Safeguards and unsupported surfaces | ||
| 242 | |||
| 243 | 1. Explicit unsupported errors for `respondToRequest` and `rollbackThread` (v1). | ||
| 244 | 2. Guard checkpoint-revert path for Cursor threads. | ||
| 245 | 3. Document limitations in UI/help text. | ||
| 246 | |||
| 247 | --- | ||
| 248 | |||
| 249 | ## 8) Test Plan | ||
| 250 | |||
| 251 | Follow project rule: backend external-service integrations tested via layered fakes, not by mocking core business logic. | ||
| 252 | |||
| 253 | ### 8.1 Unit tests (`CursorAdapter`) | ||
| 254 | |||
| 255 | 1. stream JSON parse and mapping: | ||
| 256 | - assistant partial/final | ||
| 257 | - each tool type | ||
| 258 | - shell success/failure/rejected | ||
| 259 | - non-JSON line failure | ||
| 260 | 2. process lifecycle: | ||
| 261 | - start/send/interrupt/stop | ||
| 262 | - stale session errors | ||
| 263 | 3. ID sanitization: | ||
| 264 | - `call_id` with newline. | ||
| 265 | |||
| 266 | ### 8.2 Provider service/routing tests | ||
| 267 | |||
| 268 | 1. Registry resolves `cursor`. | ||
| 269 | 2. Session directory persistence reads/writes `cursor` provider. | ||
| 270 | 3. ProviderService fan-out/order with Cursor events. | ||
| 271 | |||
| 272 | ### 8.3 Orchestration tests | ||
| 273 | |||
| 274 | 1. `thread.turn.start` with `provider: cursor` routes to Cursor adapter. | ||
| 275 | 2. checkpoint revert on Cursor thread returns controlled failure activity. | ||
| 276 | 3. approval response command on Cursor session surfaces “unsupported approval API”. | ||
| 277 | |||
| 278 | ### 8.4 Smoke/integration harness | ||
| 279 | |||
| 280 | 1. Optional local smoke behind env flag (`CURSOR_SMOKE=1`) to run real `agent`: | ||
| 281 | - create session | ||
| 282 | - send simple turn | ||
| 283 | - verify message delta/completion + turn completion. | ||
| 284 | |||
| 285 | --- | ||
| 286 | |||
| 287 | ## 9) Operational Notes | ||
| 288 | |||
| 289 | 1. Do not run concurrent `agent` processes per workspace by default; serialize per adapter session to avoid config races. | ||
| 290 | 2. Keep native event NDJSON logs for Cursor similar to Codex adapter logs. | ||
| 291 | 3. Treat startup plain-text failures as process-level errors and surface full message. | ||
| 292 | |||
| 293 | --- | ||
| 294 | |||
| 295 | ## 10) Open Questions | ||
| 296 | |||
| 297 | 1. Should Cursor provider expose full model list dynamically (`agent --list-models`) or use curated static list in contracts? | ||
| 298 | 2. Should T3 keep strict approval UX parity with Codex, or accept Cursor’s current non-interactive approval limitation in v1? | ||
| 299 | 3. Is checkpoint revert required for initial Cursor release, or can it ship as explicitly unsupported? | ||
| 300 | |||
| 301 | --- | ||
| 302 | |||
| 303 | ## 11) Delivery Checklist | ||
| 304 | |||
| 305 | 1. Contracts updated (`ProviderKind`, provider start options, models). | ||
| 306 | 2. Cursor adapter + layer implemented and registered. | ||
| 307 | 3. Provider selection precedence fixed in `ProviderCommandReactor`. | ||
| 308 | 4. Cursor enabled in web provider picker. | ||
| 309 | 5. Tests added for adapter mapping, routing, and orchestration behavior. | ||
| 310 | 6. Lint + test suite green. | ||
apps/server/src/provider/Services/CursorAdapter.test.ts created+132| ... | @@ -0,0 +1,132 @@ | ||
| 1 | import { Schema } from "effect"; | ||
| 2 | import { describe, expect, it } from "vitest"; | ||
| 3 | |||
| 4 | import { CursorCliStreamEvent } from "./CursorAdapter.ts"; | ||
| 5 | |||
| 6 | describe("CursorCliStreamEvent", () => { | ||
| 7 | it("decodes system/init events", () => { | ||
| 8 | const decoded = Schema.decodeUnknownSync(CursorCliStreamEvent)({ | ||
| 9 | type: "system", | ||
| 10 | subtype: "init", | ||
| 11 | session_id: "sess-1", | ||
| 12 | apiKeySource: "login", | ||
| 13 | cwd: "/tmp/project", | ||
| 14 | model: "Auto", | ||
| 15 | permissionMode: "default", | ||
| 16 | }); | ||
| 17 | |||
| 18 | expect(decoded.type).toBe("system"); | ||
| 19 | if (decoded.type !== "system") { | ||
| 20 | return; | ||
| 21 | } | ||
| 22 | expect(decoded.subtype).toBe("init"); | ||
| 23 | expect(decoded.session_id).toBe("sess-1"); | ||
| 24 | }); | ||
| 25 | |||
| 26 | it("decodes thinking delta/completed events", () => { | ||
| 27 | const delta = Schema.decodeUnknownSync(CursorCliStreamEvent)({ | ||
| 28 | type: "thinking", | ||
| 29 | subtype: "delta", | ||
| 30 | text: "draft", | ||
| 31 | session_id: "sess-1", | ||
| 32 | timestamp_ms: 123, | ||
| 33 | }); | ||
| 34 | |||
| 35 | expect(delta.type).toBe("thinking"); | ||
| 36 | if (delta.type !== "thinking") { | ||
| 37 | return; | ||
| 38 | } | ||
| 39 | expect(delta.subtype).toBe("delta"); | ||
| 40 | |||
| 41 | const completed = Schema.decodeUnknownSync(CursorCliStreamEvent)({ | ||
| 42 | type: "thinking", | ||
| 43 | subtype: "completed", | ||
| 44 | session_id: "sess-1", | ||
| 45 | }); | ||
| 46 | |||
| 47 | expect(completed.type).toBe("thinking"); | ||
| 48 | if (completed.type !== "thinking") { | ||
| 49 | return; | ||
| 50 | } | ||
| 51 | expect(completed.subtype).toBe("completed"); | ||
| 52 | }); | ||
| 53 | |||
| 54 | it("decodes tool_call completed events with rejected results", () => { | ||
| 55 | const decoded = Schema.decodeUnknownSync(CursorCliStreamEvent)({ | ||
| 56 | type: "tool_call", | ||
| 57 | subtype: "completed", | ||
| 58 | call_id: "tool-1\nline-2", | ||
| 59 | session_id: "sess-1", | ||
| 60 | tool_call: { | ||
| 61 | shellToolCall: { | ||
| 62 | args: { | ||
| 63 | command: "rm -rf /tmp/nope", | ||
| 64 | }, | ||
| 65 | result: { | ||
| 66 | rejected: { | ||
| 67 | reason: "approval denied", | ||
| 68 | }, | ||
| 69 | }, | ||
| 70 | }, | ||
| 71 | }, | ||
| 72 | }); | ||
| 73 | |||
| 74 | expect(decoded.type).toBe("tool_call"); | ||
| 75 | if (decoded.type !== "tool_call") { | ||
| 76 | return; | ||
| 77 | } | ||
| 78 | expect(decoded.subtype).toBe("completed"); | ||
| 79 | expect(decoded.call_id).toContain("\n"); | ||
| 80 | }); | ||
| 81 | |||
| 82 | it("decodes result success events with usage payload", () => { | ||
| 83 | const decoded = Schema.decodeUnknownSync(CursorCliStreamEvent)({ | ||
| 84 | type: "result", | ||
| 85 | subtype: "success", | ||
| 86 | duration_ms: 100, | ||
| 87 | duration_api_ms: 90, | ||
| 88 | is_error: false, | ||
| 89 | result: "OK", | ||
| 90 | session_id: "sess-1", | ||
| 91 | request_id: "req-1", | ||
| 92 | usage: { | ||
| 93 | inputTokens: 10, | ||
| 94 | outputTokens: 5, | ||
| 95 | cacheReadTokens: 2, | ||
| 96 | cacheWriteTokens: 1, | ||
| 97 | }, | ||
| 98 | }); | ||
| 99 | |||
| 100 | expect(decoded.type).toBe("result"); | ||
| 101 | if (decoded.type !== "result") { | ||
| 102 | return; | ||
| 103 | } | ||
| 104 | expect(decoded.subtype).toBe("success"); | ||
| 105 | expect(decoded.is_error).toBe(false); | ||
| 106 | }); | ||
| 107 | |||
| 108 | it("decodes connection/retry lifecycle events", () => { | ||
| 109 | const reconnecting = Schema.decodeUnknownSync(CursorCliStreamEvent)({ | ||
| 110 | type: "connection", | ||
| 111 | subtype: "reconnecting", | ||
| 112 | session_id: "sess-1", | ||
| 113 | }); | ||
| 114 | expect(reconnecting.type).toBe("connection"); | ||
| 115 | |||
| 116 | const retry = Schema.decodeUnknownSync(CursorCliStreamEvent)({ | ||
| 117 | type: "retry", | ||
| 118 | subtype: "resuming", | ||
| 119 | session_id: "sess-1", | ||
| 120 | }); | ||
| 121 | expect(retry.type).toBe("retry"); | ||
| 122 | }); | ||
| 123 | |||
| 124 | it("rejects unsupported stream event types", () => { | ||
| 125 | expect(() => | ||
| 126 | Schema.decodeUnknownSync(CursorCliStreamEvent)({ | ||
| 127 | type: "bogus", | ||
| 128 | session_id: "sess-1", | ||
| 129 | }), | ||
| 130 | ).toThrow(); | ||
| 131 | }); | ||
| 132 | }); | ||
apps/server/src/provider/Services/CursorAdapter.ts created+214| ... | @@ -0,0 +1,214 @@ | ||
| 1 | /** | ||
| 2 | * CursorAdapter - Cursor CLI implementation of the generic provider adapter contract. | ||
| 3 | * | ||
| 4 | * This service will own Cursor CLI (`agent`) process / stream-json semantics and emit | ||
| 5 | * canonical provider runtime events via the shared provider adapter contract. | ||
| 6 | * | ||
| 7 | * This file intentionally defines raw Cursor stream-json schemas up front so the future | ||
| 8 | * layer implementation can decode/validate NDJSON lines in a single place. | ||
| 9 | * | ||
| 10 | * @module CursorAdapter | ||
| 11 | */ | ||
| 12 | import { Schema, ServiceMap } from "effect"; | ||
| 13 | |||
| 14 | import type { ProviderAdapterError } from "../Errors.ts"; | ||
| 15 | import type { ProviderAdapterShape } from "./ProviderAdapter.ts"; | ||
| 16 | |||
| 17 | export const CursorCliSessionId = Schema.String.check(Schema.isNonEmpty()); | ||
| 18 | export type CursorCliSessionId = typeof CursorCliSessionId.Type; | ||
| 19 | |||
| 20 | export const CursorCliTextContentPart = Schema.Struct({ | ||
| 21 | type: Schema.Literal("text"), | ||
| 22 | text: Schema.String, | ||
| 23 | }); | ||
| 24 | export type CursorCliTextContentPart = typeof CursorCliTextContentPart.Type; | ||
| 25 | |||
| 26 | export const CursorCliContentPart = Schema.Union([ | ||
| 27 | CursorCliTextContentPart, | ||
| 28 | Schema.Struct({ | ||
| 29 | type: Schema.String, | ||
| 30 | }), | ||
| 31 | ]); | ||
| 32 | export type CursorCliContentPart = typeof CursorCliContentPart.Type; | ||
| 33 | |||
| 34 | export const CursorCliUserMessage = Schema.Struct({ | ||
| 35 | role: Schema.Literal("user"), | ||
| 36 | content: Schema.Array(CursorCliContentPart), | ||
| 37 | }); | ||
| 38 | export type CursorCliUserMessage = typeof CursorCliUserMessage.Type; | ||
| 39 | |||
| 40 | export const CursorCliAssistantMessage = Schema.Struct({ | ||
| 41 | role: Schema.Literal("assistant"), | ||
| 42 | content: Schema.Array(CursorCliContentPart), | ||
| 43 | }); | ||
| 44 | export type CursorCliAssistantMessage = typeof CursorCliAssistantMessage.Type; | ||
| 45 | |||
| 46 | export const CursorCliToolCallResult = Schema.Struct({ | ||
| 47 | success: Schema.optional(Schema.Unknown), | ||
| 48 | failure: Schema.optional(Schema.Unknown), | ||
| 49 | rejected: Schema.optional(Schema.Unknown), | ||
| 50 | }); | ||
| 51 | export type CursorCliToolCallResult = typeof CursorCliToolCallResult.Type; | ||
| 52 | |||
| 53 | export const CursorCliToolCallEntry = Schema.Struct({ | ||
| 54 | args: Schema.optional(Schema.Unknown), | ||
| 55 | result: Schema.optional(CursorCliToolCallResult), | ||
| 56 | }); | ||
| 57 | export type CursorCliToolCallEntry = typeof CursorCliToolCallEntry.Type; | ||
| 58 | |||
| 59 | export const CursorCliFunctionToolCall = Schema.Struct({ | ||
| 60 | name: Schema.String, | ||
| 61 | arguments: Schema.String, | ||
| 62 | result: Schema.optional(CursorCliToolCallResult), | ||
| 63 | }); | ||
| 64 | export type CursorCliFunctionToolCall = typeof CursorCliFunctionToolCall.Type; | ||
| 65 | |||
| 66 | export const CursorCliToolCallPayload = Schema.Struct({ | ||
| 67 | readToolCall: Schema.optional(CursorCliToolCallEntry), | ||
| 68 | writeToolCall: Schema.optional(CursorCliToolCallEntry), | ||
| 69 | editToolCall: Schema.optional(CursorCliToolCallEntry), | ||
| 70 | shellToolCall: Schema.optional(CursorCliToolCallEntry), | ||
| 71 | grepToolCall: Schema.optional(CursorCliToolCallEntry), | ||
| 72 | globToolCall: Schema.optional(CursorCliToolCallEntry), | ||
| 73 | function: Schema.optional(CursorCliFunctionToolCall), | ||
| 74 | }); | ||
| 75 | export type CursorCliToolCallPayload = typeof CursorCliToolCallPayload.Type; | ||
| 76 | |||
| 77 | export const CursorCliTokenUsage = Schema.Struct({ | ||
| 78 | inputTokens: Schema.optional(Schema.Number), | ||
| 79 | outputTokens: Schema.optional(Schema.Number), | ||
| 80 | cacheReadTokens: Schema.optional(Schema.Number), | ||
| 81 | cacheWriteTokens: Schema.optional(Schema.Number), | ||
| 82 | }); | ||
| 83 | export type CursorCliTokenUsage = typeof CursorCliTokenUsage.Type; | ||
| 84 | |||
| 85 | const CursorCliTimestampMs = { | ||
| 86 | timestamp_ms: Schema.optional(Schema.Number), | ||
| 87 | } as const; | ||
| 88 | |||
| 89 | export const CursorCliSystemInitEvent = Schema.Struct({ | ||
| 90 | type: Schema.Literal("system"), | ||
| 91 | subtype: Schema.Literal("init"), | ||
| 92 | session_id: CursorCliSessionId, | ||
| 93 | apiKeySource: Schema.optional(Schema.String), | ||
| 94 | cwd: Schema.optional(Schema.String), | ||
| 95 | model: Schema.optional(Schema.String), | ||
| 96 | permissionMode: Schema.optional(Schema.String), | ||
| 97 | ...CursorCliTimestampMs, | ||
| 98 | }); | ||
| 99 | export type CursorCliSystemInitEvent = typeof CursorCliSystemInitEvent.Type; | ||
| 100 | |||
| 101 | export const CursorCliUserEvent = Schema.Struct({ | ||
| 102 | type: Schema.Literal("user"), | ||
| 103 | message: CursorCliUserMessage, | ||
| 104 | session_id: CursorCliSessionId, | ||
| 105 | ...CursorCliTimestampMs, | ||
| 106 | }); | ||
| 107 | export type CursorCliUserEvent = typeof CursorCliUserEvent.Type; | ||
| 108 | |||
| 109 | export const CursorCliAssistantEvent = Schema.Struct({ | ||
| 110 | type: Schema.Literal("assistant"), | ||
| 111 | message: CursorCliAssistantMessage, | ||
| 112 | session_id: CursorCliSessionId, | ||
| 113 | ...CursorCliTimestampMs, | ||
| 114 | }); | ||
| 115 | export type CursorCliAssistantEvent = typeof CursorCliAssistantEvent.Type; | ||
| 116 | |||
| 117 | export const CursorCliThinkingDeltaEvent = Schema.Struct({ | ||
| 118 | type: Schema.Literal("thinking"), | ||
| 119 | subtype: Schema.Literal("delta"), | ||
| 120 | text: Schema.String, | ||
| 121 | session_id: CursorCliSessionId, | ||
| 122 | ...CursorCliTimestampMs, | ||
| 123 | }); | ||
| 124 | export type CursorCliThinkingDeltaEvent = typeof CursorCliThinkingDeltaEvent.Type; | ||
| 125 | |||
| 126 | export const CursorCliThinkingCompletedEvent = Schema.Struct({ | ||
| 127 | type: Schema.Literal("thinking"), | ||
| 128 | subtype: Schema.Literal("completed"), | ||
| 129 | session_id: CursorCliSessionId, | ||
| 130 | ...CursorCliTimestampMs, | ||
| 131 | }); | ||
| 132 | export type CursorCliThinkingCompletedEvent = typeof CursorCliThinkingCompletedEvent.Type; | ||
| 133 | |||
| 134 | export const CursorCliToolCallStartedEvent = Schema.Struct({ | ||
| 135 | type: Schema.Literal("tool_call"), | ||
| 136 | subtype: Schema.Literal("started"), | ||
| 137 | call_id: Schema.String, | ||
| 138 | tool_call: CursorCliToolCallPayload, | ||
| 139 | session_id: CursorCliSessionId, | ||
| 140 | ...CursorCliTimestampMs, | ||
| 141 | }); | ||
| 142 | export type CursorCliToolCallStartedEvent = typeof CursorCliToolCallStartedEvent.Type; | ||
| 143 | |||
| 144 | export const CursorCliToolCallCompletedEvent = Schema.Struct({ | ||
| 145 | type: Schema.Literal("tool_call"), | ||
| 146 | subtype: Schema.Literal("completed"), | ||
| 147 | call_id: Schema.String, | ||
| 148 | tool_call: CursorCliToolCallPayload, | ||
| 149 | session_id: CursorCliSessionId, | ||
| 150 | ...CursorCliTimestampMs, | ||
| 151 | }); | ||
| 152 | export type CursorCliToolCallCompletedEvent = typeof CursorCliToolCallCompletedEvent.Type; | ||
| 153 | |||
| 154 | export const CursorCliResultSuccessEvent = Schema.Struct({ | ||
| 155 | type: Schema.Literal("result"), | ||
| 156 | subtype: Schema.Literal("success"), | ||
| 157 | duration_ms: Schema.Number, | ||
| 158 | duration_api_ms: Schema.optional(Schema.Number), | ||
| 159 | is_error: Schema.Boolean, | ||
| 160 | result: Schema.String, | ||
| 161 | session_id: CursorCliSessionId, | ||
| 162 | request_id: Schema.optional(Schema.String), | ||
| 163 | usage: Schema.optional(CursorCliTokenUsage), | ||
| 164 | ...CursorCliTimestampMs, | ||
| 165 | }); | ||
| 166 | export type CursorCliResultSuccessEvent = typeof CursorCliResultSuccessEvent.Type; | ||
| 167 | |||
| 168 | export const CursorCliConnectionEvent = Schema.Struct({ | ||
| 169 | type: Schema.Literal("connection"), | ||
| 170 | subtype: Schema.Literals(["reconnecting", "reconnected"]), | ||
| 171 | session_id: Schema.optional(CursorCliSessionId), | ||
| 172 | ...CursorCliTimestampMs, | ||
| 173 | }); | ||
| 174 | export type CursorCliConnectionEvent = typeof CursorCliConnectionEvent.Type; | ||
| 175 | |||
| 176 | export const CursorCliRetryEvent = Schema.Struct({ | ||
| 177 | type: Schema.Literal("retry"), | ||
| 178 | subtype: Schema.Literals(["starting", "resuming"]), | ||
| 179 | session_id: Schema.optional(CursorCliSessionId), | ||
| 180 | ...CursorCliTimestampMs, | ||
| 181 | }); | ||
| 182 | export type CursorCliRetryEvent = typeof CursorCliRetryEvent.Type; | ||
| 183 | |||
| 184 | export const CursorCliStreamEvent = Schema.Union([ | ||
| 185 | CursorCliSystemInitEvent, | ||
| 186 | CursorCliUserEvent, | ||
| 187 | CursorCliAssistantEvent, | ||
| 188 | CursorCliThinkingDeltaEvent, | ||
| 189 | CursorCliThinkingCompletedEvent, | ||
| 190 | CursorCliToolCallStartedEvent, | ||
| 191 | CursorCliToolCallCompletedEvent, | ||
| 192 | CursorCliResultSuccessEvent, | ||
| 193 | CursorCliConnectionEvent, | ||
| 194 | CursorCliRetryEvent, | ||
| 195 | ]); | ||
| 196 | export type CursorCliStreamEvent = typeof CursorCliStreamEvent.Type; | ||
| 197 | |||
| 198 | /** | ||
| 199 | * CursorAdapterShape - Service API for the Cursor provider adapter. | ||
| 200 | * | ||
| 201 | * `provider` is intentionally narrowed to `"cursor"` here. Until contracts add | ||
| 202 | * Cursor to `ProviderKind`, this shape is defined via `Omit<...,"provider">`. | ||
| 203 | */ | ||
| 204 | export interface CursorAdapterShape | ||
| 205 | extends Omit<ProviderAdapterShape<ProviderAdapterError>, "provider"> { | ||
| 206 | readonly provider: "cursor"; | ||
| 207 | } | ||
| 208 | |||
| 209 | /** | ||
| 210 | * CursorAdapter - Service tag for Cursor provider adapter operations. | ||
| 211 | */ | ||
| 212 | export class CursorAdapter extends ServiceMap.Service<CursorAdapter, CursorAdapterShape>()( | ||
| 213 | "t3/provider/Services/CursorAdapter", | ||
| 214 | ) {} | ||