diff --git a/apps/platform-cloudflare/entry.ts b/apps/platform-cloudflare/entry.ts index 3eca4b32e..6a3f0a980 100644 --- a/apps/platform-cloudflare/entry.ts +++ b/apps/platform-cloudflare/entry.ts @@ -13,6 +13,7 @@ import { // exported name on the Worker module. The wrangler `migrations.new_sqlite_classes` // entry must match this export. export { BroadcastDO } from './src/broadcast-do.ts'; +export { DurableHttpSessionDO } from './src/durable-http-session-do.ts'; initBackgroundSchedulerResolver(c => promise => c.executionCtx.waitUntil(promise)); diff --git a/apps/platform-cloudflare/package.json b/apps/platform-cloudflare/package.json index 6648752f9..b54fa6660 100644 --- a/apps/platform-cloudflare/package.json +++ b/apps/platform-cloudflare/package.json @@ -10,6 +10,7 @@ "@floway-dev/gateway": "workspace:*", "@floway-dev/http": "workspace:*", "@floway-dev/platform": "workspace:*", + "@floway-dev/proxy": "workspace:*", "@floway-dev/protocols": "workspace:*", "hono": "^4" }, diff --git a/apps/platform-cloudflare/src/_held-socket.ts b/apps/platform-cloudflare/src/_held-socket.ts new file mode 100644 index 000000000..0f476f928 --- /dev/null +++ b/apps/platform-cloudflare/src/_held-socket.ts @@ -0,0 +1,26 @@ +// Internal helper (underscore prefix = not a platform export): adapts a +// DialedSocket into the DuplexStream shape `@floway-dev/http` consumes, plus +// an idempotent close. +// +// Why this exists as its own ~tiny class: the DurableHttpSessionDO is the +// first piece of code in the workspace that OWNS a live outbound socket across +// inbound requests. If a future need arises for a raw persistent TCP duplex +// (not HTTP), HeldSocket is the seam to promote into a platform `DurableSocketDial` +// contract + a `DurableSocketDO` — a mechanical refactor rather than a rewrite. + +import type { DuplexStream } from '@floway-dev/http'; +import type { DialedSocket } from '@floway-dev/platform'; + +export class HeldSocket { + constructor(private readonly dialed: DialedSocket) {} + + /** The duplex view `fetchOnStream` reads/writes. */ + asDuplex(): DuplexStream { + return { readable: this.dialed.readable, writable: this.dialed.writable }; + } + + /** Idempotent — safe to call on an already-closed/errored socket. */ + close(): Promise { + return this.dialed.close(); + } +} diff --git a/apps/platform-cloudflare/src/bootstrap.ts b/apps/platform-cloudflare/src/bootstrap.ts index 5030af132..7f04580cc 100644 --- a/apps/platform-cloudflare/src/bootstrap.ts +++ b/apps/platform-cloudflare/src/bootstrap.ts @@ -1,4 +1,5 @@ import { DurableObjectChannelBroker, type BroadcastNamespace } from './do-channel-broker.ts'; +import { DurableObjectDurableHttpSession, type DurableHttpSessionNamespace } from './do-durable-http-session.ts'; import { createCloudflareImageProcessor, type ImagesBinding } from './image-processor.ts'; import { KvImageCache, type KvNamespace } from './kv-image-cache.ts'; import { R2FileProvider, type R2BucketLike } from './r2-file-provider.ts'; @@ -10,6 +11,7 @@ import type { DumpMetadata } from '@floway-dev/gateway/dump-types'; import { addTrustedRootCAs } from '@floway-dev/http'; import { IMAGE_CACHE_POLICY, + initDurableHttpSession, initEnv, initFileProvider, initImageCacheStore, @@ -25,15 +27,17 @@ export interface CloudflareEnv { IMAGES: ImagesBinding; KV: KvNamespace; BROADCAST_DO: BroadcastNamespace; + DURABLE_HTTP_SESSION: DurableHttpSessionNamespace; [key: string]: unknown; } // Every binding declared on `CloudflareEnv` is load-bearing — D1 holds all // config and telemetry, R2 holds spilled payloads, Images compresses inline -// images, KV memoises compressed image results. A missing binding means -// wrangler.jsonc drifted from the code, so we refuse to initialise rather -// than 503 on first use of the absent binding. -const REQUIRED_BINDINGS = ['DB', 'FILES', 'IMAGES', 'KV', 'BROADCAST_DO'] as const; +// images, KV memoises compressed image results, BROADCAST_DO fans out dump +// events, DURABLE_HTTP_SESSION holds cross-request upstream response streams +// (cursor RunSSE). A missing binding means wrangler.jsonc drifted from the +// code, so we refuse to initialise rather than 503 on first use. +const REQUIRED_BINDINGS = ['DB', 'FILES', 'IMAGES', 'KV', 'BROADCAST_DO', 'DURABLE_HTTP_SESSION'] as const; export const bootstrapCloudflarePlatform = (env: CloudflareEnv): { db: SqlDatabase } => { const missing = REQUIRED_BINDINGS.filter(name => env[name] === undefined); @@ -58,5 +62,6 @@ export const bootstrapCloudflarePlatform = (env: CloudflareEnv): { db: SqlDataba addTrustedRootCAs(cloudflareRuntimeRootCAs); initDumpStore(new FileDumpStore(env.DB, files)); initDumpBroker(new DurableObjectChannelBroker(env.BROADCAST_DO, dumpCodec)); + initDurableHttpSession(new DurableObjectDurableHttpSession(env.DURABLE_HTTP_SESSION)); return { db: env.DB }; }; diff --git a/apps/platform-cloudflare/src/broadcast-do_test.ts b/apps/platform-cloudflare/src/broadcast-do_test.ts index 70876eba3..cd91cc688 100644 --- a/apps/platform-cloudflare/src/broadcast-do_test.ts +++ b/apps/platform-cloudflare/src/broadcast-do_test.ts @@ -52,6 +52,12 @@ class FakeState { push(ws: FakeWebSocket): void { this.sockets.push(ws); } + // BroadcastDO never touches storage; present only to satisfy the shared + // DurableObjectState type (DurableHttpSessionDO uses setAlarm). + storage = { + async setAlarm(): Promise {}, + async deleteAlarm(): Promise {}, + }; } test('BroadcastDO extends DurableObject so the runtime gates RPC dispatch on it', () => { diff --git a/apps/platform-cloudflare/src/cloudflare-workers.d.ts b/apps/platform-cloudflare/src/cloudflare-workers.d.ts index 303ed6402..6b6c7c0c9 100644 --- a/apps/platform-cloudflare/src/cloudflare-workers.d.ts +++ b/apps/platform-cloudflare/src/cloudflare-workers.d.ts @@ -20,9 +20,14 @@ declare module 'cloudflare:workers' { } } -// The runtime's `DurableObjectState` surface the actor touches — just the -// WebSocket Hibernation entry points. +// The runtime's `DurableObjectState` surface the actor touches — the +// WebSocket Hibernation entry points and the alarm scheduler (used by +// DurableHttpSessionDO for idle eviction). interface DurableObjectState { acceptWebSocket(server: WebSocket): void; getWebSockets(): WebSocket[]; + storage: { + setAlarm(scheduledTime: number): Promise; + deleteAlarm(): Promise; + }; } diff --git a/apps/platform-cloudflare/src/do-durable-http-session.ts b/apps/platform-cloudflare/src/do-durable-http-session.ts new file mode 100644 index 000000000..b74e0e1f1 --- /dev/null +++ b/apps/platform-cloudflare/src/do-durable-http-session.ts @@ -0,0 +1,173 @@ +import type { + DurableHttpSessionStartInit, + DurableHttpSessionStartResult, +} from './durable-http-session-do.ts'; +import type { + DurableHttpSession, + DurableHttpSessionAcquireOptions, + DurableHttpSessionHandle, + DurableHttpSessionInit, +} from '@floway-dev/platform'; + +// CF implementation of the DurableHttpSession contract, mirroring the +// DurableObjectChannelBroker shape: resolve a per-sessionKey DO stub, then +// drive it over RPC + a WebSocket body channel. The DO holds the live outbound +// RunSSE response; this broker exposes it as the contract's ReadableStream. + +const DEFAULT_IDLE_TIMEOUT_MS = 5 * 60 * 1000; + +// One credit = "send me the next chunk". The broker sends exactly one per +// ReadableStream pull; the DO treats any inbound message as a credit and ignores +// the content, so a 1-byte sentinel is enough. +const CREDIT_BYTE = new Uint8Array([1]); + +// Minimal namespace/stub surface — declared locally so this file stays off +// `@cloudflare/workers-types` (same convention as do-channel-broker.ts). +export interface DurableHttpSessionNamespace { + idFromName(name: string): unknown; + get(id: unknown): DurableHttpSessionStub; +} + +interface DurableHttpSessionStub { + queryOrStart( + init: DurableHttpSessionStartInit | null, + idleTimeoutMs: number, + ): Promise; + release(): Promise; + discard(reason: string): Promise; + fetch(request: Request): Promise; +} + +export class DurableObjectDurableHttpSession implements DurableHttpSession { + constructor(private readonly namespace: DurableHttpSessionNamespace) {} + + private stub(sessionKey: string): DurableHttpSessionStub { + return this.namespace.get(this.namespace.idFromName(sessionKey)); + } + + async acquire( + sessionKey: string, + init: DurableHttpSessionInit | null, + opts?: DurableHttpSessionAcquireOptions, + ): Promise { + const stub = this.stub(sessionKey); + const idleTimeoutMs = opts?.idleTimeoutMs ?? DEFAULT_IDLE_TIMEOUT_MS; + + const meta = await stub.queryOrStart(init, idleTimeoutMs); + if (!meta) return null; // miss + init=null → caller degrades + + const wsResponse = await stub.fetch(new Request('https://durable-http.do/body', { + headers: { Upgrade: 'websocket' }, + })); + if (wsResponse.status !== 101) { + throw new Error(`DurableHttpSessionDO body upgrade returned HTTP ${wsResponse.status} instead of 101`); + } + const socket = (wsResponse as Response & { webSocket?: WebSocket }).webSocket; + if (!socket) throw new Error('DurableHttpSessionDO returned 101 without a webSocket'); + socket.accept(); + + const body = wsToByteStream(socket, opts?.signal); + + return { + status: meta.status, + headers: new Headers(meta.headers), + body, + async release(): Promise { + try { socket.close(1000, 'released'); } catch { /* already closed */ } + await stub.release(); + }, + async discard(reason: string): Promise { + try { socket.close(1000, 'discarded'); } catch { /* already closed */ } + await stub.discard(reason); + }, + }; + } +} + +// Wrap the body WebSocket as a ReadableStream. Binary frames are +// the body bytes; a close ends the stream; an error rejects the pending read. +// Aborting the caller signal closes the socket and ends the stream — it does +// NOT discard the DO session (sibling acquires keep working), matching the +// contract's signal semantics. +const wsToByteStream = ( + socket: WebSocket, + signal: AbortSignal | undefined, +): ReadableStream => { + // Head-index cursor over a push-only array so a burst that queues dozens of + // chunks before the consumer resumes doesn't pay O(n) per delivered chunk. + const queue: Uint8Array[] = []; + let queueHead = 0; + let pull: ((v: { value?: Uint8Array; done: boolean }) => void) | null = null; + let pendingError: unknown = null; + let closed = false; + + const deliver = (chunk: Uint8Array | null): void => { + if (pull) { + const r = pull; + pull = null; + r(chunk ? { value: chunk, done: false } : { done: true }); + } else if (chunk) { + queue.push(chunk); + } + }; + + const end = (): void => { + if (closed) return; + closed = true; + deliver(null); + try { socket.close(1000, 'consumer done'); } catch { /* already closed */ } + }; + + socket.addEventListener('message', event => { + if (closed) return; + const raw = (event as MessageEvent).data as string | ArrayBuffer | Uint8Array; + let bytes: Uint8Array; + if (typeof raw === 'string') bytes = new TextEncoder().encode(raw); + else if (raw instanceof Uint8Array) bytes = raw; + else bytes = new Uint8Array(raw); + deliver(bytes); + }); + socket.addEventListener('close', () => { closed = true; deliver(null); }); + socket.addEventListener('error', () => { + if (pendingError === null) pendingError = new Error('DurableHttpSession body socket error'); + closed = true; + deliver(null); + }); + + if (signal) { + if (signal.aborted) end(); + else signal.addEventListener('abort', end, { once: true }); + } + + return new ReadableStream({ + async pull(controller): Promise { + if (queueHead < queue.length) { + controller.enqueue(queue[queueHead]!); + queueHead += 1; + if (queueHead >= queue.length) { + queue.length = 0; + queueHead = 0; + } + return; + } + if (closed) { + if (pendingError) { controller.error(pendingError); return; } + controller.close(); + return; + } + // Credit-based backpressure: request exactly one chunk, then wait for it. + // With highWaterMark 0 the stream only pulls on an active read, so a + // consumer that pauses (transport parked at exec_mcp) sends no credit and + // the DO holds subsequent chunks in its buffer for the next acquire. + try { socket.send(CREDIT_BYTE); } catch { controller.close(); return; } + const result = await new Promise<{ value?: Uint8Array; done: boolean }>(resolve => { pull = resolve; }); + if (result.done) { + if (pendingError) controller.error(pendingError); + else controller.close(); + return; + } + if (result.value) controller.enqueue(result.value); + }, + cancel(): void { end(); }, + }, { highWaterMark: 0 }); +}; diff --git a/apps/platform-cloudflare/src/do-durable-http-session_test.ts b/apps/platform-cloudflare/src/do-durable-http-session_test.ts new file mode 100644 index 000000000..68c7ef99f --- /dev/null +++ b/apps/platform-cloudflare/src/do-durable-http-session_test.ts @@ -0,0 +1,164 @@ +import { describe, expect, test, vi } from 'vitest'; + +import { DurableObjectDurableHttpSession, type DurableHttpSessionNamespace } from './do-durable-http-session.ts'; + +// Fake WebSocket the broker treats as the DO body channel. Tests drive it by +// calling emitMessage / emitClose after the broker has wired its listeners. The +// broker sends one credit (send) per ReadableStream pull; `credits` records them +// and `onCredit` lets a test script a frame per credit (the real DO's behavior). +class FakeWebSocket { + readonly listeners = new Map void>>(); + readonly credits: Uint8Array[] = []; + onCredit: (() => void) | null = null; + closed: { code: number; reason: string } | null = null; + accepted = false; + accept(): void { this.accepted = true; } + send(data: Uint8Array): void { this.credits.push(data); this.onCredit?.(); } + close(code = 1000, reason = ''): void { + if (this.closed) return; + this.closed = { code, reason }; + this.fire('close', { code, reason }); + } + addEventListener(type: string, fn: (ev: unknown) => void): void { + if (!this.listeners.has(type)) this.listeners.set(type, new Set()); + this.listeners.get(type)!.add(fn); + } + removeEventListener(type: string, fn: (ev: unknown) => void): void { + this.listeners.get(type)?.delete(fn); + } + private fire(type: string, ev: unknown): void { + for (const fn of this.listeners.get(type) ?? []) fn(ev); + } + emitMessage(data: Uint8Array | string): void { this.fire('message', { data }); } + emitClose(): void { this.close(1000, 'upstream ended'); } +} + +interface StubCalls { + queryOrStart: Array<{ init: unknown; idleTimeoutMs: number }>; + released: number; + discarded: string[]; +} + +function makeNamespace(opts: { + meta: { status: number; headers: [string, string][] } | null; + socket?: FakeWebSocket; +}): { ns: DurableHttpSessionNamespace; calls: StubCalls; idFromName: ReturnType } { + const calls: StubCalls = { queryOrStart: [], released: 0, discarded: [] }; + const socket = opts.socket ?? new FakeWebSocket(); + const stub = { + async queryOrStart(init: unknown, idleTimeoutMs: number) { + calls.queryOrStart.push({ init, idleTimeoutMs }); + return opts.meta; + }, + async release() { calls.released++; }, + async discard(reason: string) { calls.discarded.push(reason); }, + async fetch() { + // CF returns 101 + webSocket on a hibernation upgrade; Node's Response + // refuses status 101, so hand back a structural stand-in. + return { status: 101, webSocket: socket } as unknown as Response; + }, + }; + const idFromName = vi.fn((name: string) => `id:${name}`); + const ns: DurableHttpSessionNamespace = { idFromName, get: () => stub }; + return { ns, calls, idFromName }; +} + +const drain = async (body: ReadableStream): Promise => { + const out: Uint8Array[] = []; + const reader = body.getReader(); + while (true) { const { done, value } = await reader.read(); if (done) break; if (value) out.push(value); } + return out; +}; + +describe('DurableObjectDurableHttpSession.acquire', () => { + test('miss + init=null returns null (queryOrStart said no session)', async () => { + const { ns, calls } = makeNamespace({ meta: null }); + const broker = new DurableObjectDurableHttpSession(ns); + const handle = await broker.acquire('cursor:up:key:auto:abc', null, { idleTimeoutMs: 1000 }); + expect(handle).toBeNull(); + expect(calls.queryOrStart).toHaveLength(1); + expect(calls.queryOrStart[0]!.idleTimeoutMs).toBe(1000); + }); + + test('hit returns a handle exposing status/headers and streaming body', async () => { + const socket = new FakeWebSocket(); + const { ns } = makeNamespace({ meta: { status: 200, headers: [['x-test', 'hi']] }, socket }); + const broker = new DurableObjectDurableHttpSession(ns); + + const handle = await broker.acquire('k', { method: 'POST', url: 'https://x', headers: {} }); + expect(handle).not.toBeNull(); + expect(handle!.status).toBe(200); + expect(handle!.headers.get('x-test')).toBe('hi'); + expect(socket.accepted).toBe(true); + + const collected = drain(handle!.body); + socket.emitMessage(new Uint8Array([1, 2, 3])); + socket.emitMessage(new Uint8Array([4])); + socket.emitClose(); + const chunks = await collected; + expect(chunks.flatMap(c => Array.from(c))).toEqual([1, 2, 3, 4]); + }); + + test('issues exactly one credit per pull (strict backpressure)', async () => { + const socket = new FakeWebSocket(); + // Respond to each credit with the next frame, then the close — modelling the + // DO's one-chunk-per-credit delivery. + const frames = [new Uint8Array([1]), new Uint8Array([2])]; + let i = 0; + socket.onCredit = () => { + queueMicrotask(() => { if (i < frames.length) socket.emitMessage(frames[i++]); else socket.emitClose(); }); + }; + const { ns } = makeNamespace({ meta: { status: 200, headers: [] }, socket }); + const broker = new DurableObjectDurableHttpSession(ns); + const handle = await broker.acquire('k', { method: 'POST', url: 'https://x', headers: {} }); + + const chunks = await drain(handle!.body); + expect(chunks.flatMap(c => Array.from(c))).toEqual([1, 2]); + // Two data chunks + the final pull that received the close = three credits. + expect(socket.credits).toHaveLength(3); + }); + + test('release closes the body socket and calls stub.release', async () => { + const socket = new FakeWebSocket(); + const { ns, calls } = makeNamespace({ meta: { status: 200, headers: [] }, socket }); + const broker = new DurableObjectDurableHttpSession(ns); + const handle = await broker.acquire('k', { method: 'POST', url: 'https://x', headers: {} }); + await handle!.release(); + expect(calls.released).toBe(1); + expect(socket.closed).not.toBeNull(); + }); + + test('discard closes the body socket and calls stub.discard with the reason', async () => { + const socket = new FakeWebSocket(); + const { ns, calls } = makeNamespace({ meta: { status: 200, headers: [] }, socket }); + const broker = new DurableObjectDurableHttpSession(ns); + const handle = await broker.acquire('k', { method: 'POST', url: 'https://x', headers: {} }); + await handle!.discard('framing error'); + expect(calls.discarded).toEqual(['framing error']); + expect(socket.closed).not.toBeNull(); + }); + + test('aborting the caller signal ends the body stream without discarding', async () => { + const socket = new FakeWebSocket(); + const { ns, calls } = makeNamespace({ meta: { status: 200, headers: [] }, socket }); + const broker = new DurableObjectDurableHttpSession(ns); + const ac = new AbortController(); + const handle = await broker.acquire('k', { method: 'POST', url: 'https://x', headers: {} }, { signal: ac.signal }); + + const collected = drain(handle!.body); + socket.emitMessage(new Uint8Array([9])); + ac.abort(); + const chunks = await collected; + expect(chunks.flatMap(c => Array.from(c))).toEqual([9]); + expect(socket.closed).not.toBeNull(); + expect(calls.discarded).toHaveLength(0); // abort != discard + }); + + test('idFromName is keyed by sessionKey so each conversation maps to its own actor', async () => { + const { ns, idFromName } = makeNamespace({ meta: { status: 200, headers: [] } }); + const broker = new DurableObjectDurableHttpSession(ns); + await broker.acquire('cursor:up:keyA:auto:1', null); + await broker.acquire('cursor:up:keyB:auto:2', null); + expect(idFromName.mock.calls.map(c => c[0])).toEqual(['cursor:up:keyA:auto:1', 'cursor:up:keyB:auto:2']); + }); +}); diff --git a/apps/platform-cloudflare/src/durable-http-session-do.ts b/apps/platform-cloudflare/src/durable-http-session-do.ts new file mode 100644 index 000000000..90a842d4c --- /dev/null +++ b/apps/platform-cloudflare/src/durable-http-session-do.ts @@ -0,0 +1,317 @@ +import { DurableObject } from 'cloudflare:workers'; + +import { HeldSocket } from './_held-socket.ts'; +import { cloudflareSocketDial } from './socket-dial.ts'; +import { fetchOnStream } from '@floway-dev/http'; +import { normalizeDialHost } from '@floway-dev/platform'; +import { runProxiedRequest, type ProxyConfig } from '@floway-dev/proxy'; + +// DurableHttpSessionDO — one instance per sessionKey. It is the first actor in +// the workspace that owns a live OUTBOUND socket: it dials the upstream with +// `cloudflare:sockets`, runs one HTTP/1.1 request over it via fetchOnStream, +// and holds the still-streaming response body open across many inbound Worker +// requests. The outbound socket keeps the DO alive (CF 2026-06-19: outbound +// connections keep DOs alive, up to a 15-minute cap); an idle alarm evicts an +// abandoned session before then. Semantic upgrade over BroadcastDO (a pure +// message bus that touches no external resource) — documented here and in +// wrangler.example.jsonc. +// +// The body bytes reach the broker (which may live in another isolate) over a +// WebSocket: the DO pumps response.body into the attached consumer socket, and +// buffers between consumers so a release()→re-acquire() continues mid-stream +// (the DurableHttpSession contract: releasing a handle must not cancel the +// upstream read). Protocol decoding stays entirely with the caller. + +// `start` reply / RPC-friendly shapes (structured-clone-safe). +export interface DurableHttpSessionStartInit { + method: 'POST' | 'GET' | 'PUT' | 'DELETE'; + url: string; + headers: Record; + body?: Uint8Array; + /** Opaque ProxyConfig[] to dial through (see DurableHttpSessionInit.proxies). */ + proxies?: unknown[]; +} + +export interface DurableHttpSessionStartResult { + status: number; + headers: [string, string][]; +} + +const DEFAULT_IDLE_TIMEOUT_MS = 5 * 60 * 1000; +// Hard cap on bytes buffered while no consumer is attached. Overflow means a +// consumer fell behind or vanished mid-stream — discard so the next acquire +// starts fresh rather than letting the actor grow unbounded. +const MAX_BUFFERED_BYTES = 1 << 20; // 1 MiB + +export class DurableHttpSessionDO extends DurableObject { + private held: HeldSocket | null = null; + private reader: ReadableStreamDefaultReader | null = null; + private statusCode = 0; + private headerList: [string, string][] = []; + private started = false; + private done = false; + private errored = false; + // Bytes read off the upstream while no consumer is attached. Chunks are + // pushed at the tail and consumed via a head-index cursor so a burst that + // buffers dozens of chunks doesn't pay O(n) per shift. + private buffer: Uint8Array[] = []; + private bufferHead = 0; + private bufferedBytes = 0; + private consumer: WebSocket | null = null; + // Chunks the current consumer has requested (one credit per broker-side + // ReadableStream pull) but not yet been sent. Reset on attach/release/discard. + private credits = 0; + private idleTimeoutMs = DEFAULT_IDLE_TIMEOUT_MS; + private lastActivityAt = 0; + + constructor(ctx: DurableObjectState, env: unknown) { + super(ctx, env); + } + + /** + * Broker entrypoint. Returns the response meta if a session is live (hit), + * starts one from `init` if not (miss + init), or returns null (miss + + * init=null) so the caller can degrade to a new conversation. + */ + async queryOrStart( + init: DurableHttpSessionStartInit | null, + idleTimeoutMs: number, + ): Promise { + if (this.started && !this.errored) { + this.touch(); + return { status: this.statusCode, headers: this.headerList }; + } + if (!init) return null; + return await this.start(init, idleTimeoutMs); + } + + private async start( + init: DurableHttpSessionStartInit, + idleTimeoutMs: number, + ): Promise { + this.idleTimeoutMs = idleTimeoutMs > 0 ? idleTimeoutMs : DEFAULT_IDLE_TIMEOUT_MS; + const url = new URL(init.url); + const tls = url.protocol === 'https:'; + const port = url.port ? Number(url.port) : (tls ? 443 : 80); + + const path = `${url.pathname}${url.search}`; + // Host is mandatory on HTTP/1.1; derive it from the URL rather than + // trusting the caller to pass it (fetchOnStream validates/forwards the rest). + const headers: Record = { Host: url.host, ...init.headers }; + let response: Response; + try { + const proxies = (init.proxies ?? []) as ProxyConfig[]; + if (proxies.length > 0) { + // Proxied dial (streaming): runProxiedRequest composes dial → TLS → + // fetch-on-stream over the CF socket dial. Try each in order. No + // HeldSocket: discard cancels the response body, which tears down the + // proxied transport. + const target = { host: normalizeDialHost(url.hostname), port, tls }; + const request = { method: init.method, path, headers, body: init.body }; + let lastErr: unknown; + let proxied: Response | null = null; + for (const config of proxies) { + try { proxied = await runProxiedRequest(config, target, request, { socketDial: cloudflareSocketDial }); break; } catch (err) { lastErr = err; } + } + if (!proxied) throw lastErr ?? new Error('all proxies failed for DurableHttpSessionDO dial'); + response = proxied; + } else { + const dialed = await cloudflareSocketDial.connect(url.hostname, port, { tls }); + this.held = new HeldSocket(dialed); + response = await fetchOnStream(this.held.asDuplex(), { + method: init.method, + path, + headers, + body: init.body, + }); + } + } catch (err) { + await this.discard(`upstream dial/request failed: ${err instanceof Error ? err.message : String(err)}`); + throw err; + } + if (!response.body) { + await this.discard('upstream returned no body'); + throw new Error(`DurableHttpSessionDO: ${init.method} ${init.url} returned no body`); + } + + this.statusCode = response.status; + this.headerList = [...response.headers]; + this.reader = response.body.getReader(); + this.started = true; + this.touch(); + this.startPump(); + await this.ctx.storage.setAlarm(Date.now() + this.idleTimeoutMs); + return { status: this.statusCode, headers: this.headerList }; + } + + // Continuously drain the upstream reader into `buffer`, then hand chunks to + // the consumer only as fast as it credits them (see flushToConsumer). A + // consumer that pauses reading stops crediting, so subsequent chunks stay + // buffered for the next acquire instead of being pushed at a WS about to close. + private startPump(): void { + const pump = async (): Promise => { + try { + while (true) { + const { done, value } = await this.reader!.read(); + if (done) { + this.done = true; + this.flushToConsumer(); + return; + } + if (!value || value.byteLength === 0) continue; + this.buffer.push(value); + this.bufferedBytes += value.byteLength; + if (this.bufferedBytes > MAX_BUFFERED_BYTES) { + await this.discard('buffer overflow with no consumer'); + return; + } + this.flushToConsumer(); + } + } catch (err) { + this.errored = true; + this.done = true; + this.consumer?.close(1011, `upstream error: ${err instanceof Error ? err.message : String(err)}`); + } + }; + void pump(); + } + + // Deliver buffered chunks to the consumer up to the credits it has requested + // (the broker sends one credit per ReadableStream pull). A send that throws + // leaves the chunk at the head of `buffer` — never dropped — and detaches the + // dead consumer so the next acquire resumes from the same point. Once the + // upstream is done and the buffer is fully drained, a waiting credit gets the + // end-of-stream close. + private flushToConsumer(): void { + while (this.consumer && this.credits > 0 && this.bufferHead < this.buffer.length) { + const chunk = this.buffer[this.bufferHead]!; + try { + this.consumer.send(chunk); + } catch { + this.consumer = null; + return; + } + this.bufferHead += 1; + this.bufferedBytes -= chunk.byteLength; + this.credits -= 1; + } + // Reclaim: once the head has walked past every live element the two ends + // are back at 0, releasing the underlying array so a subsequent burst + // doesn't grow the array unboundedly with dead prefix entries. + if (this.bufferHead > 0 && this.bufferHead >= this.buffer.length) { + this.buffer = []; + this.bufferHead = 0; + } + if (this.consumer && this.credits > 0 && this.done && this.bufferHead >= this.buffer.length) { + try { + this.consumer.close(1000, this.errored ? 'upstream error' : 'upstream ended'); + } catch { /* already closing */ } + } + } + + /** + * WebSocket upgrade from the broker. Accepts the server side, flushes any + * buffered bytes, then forwards live frames until the consumer disconnects. + */ + async fetch(_request: Request): Promise { + if (!this.started) { + return new Response('session not started', { status: 409 }); + } + const pair = new WebSocketPair(); + const client = pair[0]; + const server = pair[1]; + server.accept(); + this.attachConsumer(server); + return new Response(null, { status: 101, webSocket: client }); + } + + private attachConsumer(ws: WebSocket): void { + this.consumer = ws; + this.credits = 0; + this.touch(); + // Each inbound message is one credit — "send me one more chunk". The broker + // sends exactly one per ReadableStream pull, giving strict 1:1 backpressure. + ws.addEventListener('message', () => { + if (this.consumer !== ws) return; + this.credits += 1; + this.touch(); + this.flushToConsumer(); + }); + ws.addEventListener('close', () => { + if (this.consumer === ws) { + this.consumer = null; + this.touch(); + } + }); + ws.addEventListener('error', () => { + if (this.consumer === ws) this.consumer = null; + }); + // No eager flush and no done-close here: delivery is credit-driven, so a + // fresh consumer starts receiving only once it pulls (credits arrive). + this.flushToConsumer(); + } + + /** + * Detach the current consumer's hold. The upstream read keeps flowing into + * the buffer so the next acquire continues mid-stream. Idempotent — the + * consumer reference also clears on its own WS 'close' event. + */ + async release(): Promise { + // Detach the consumer and drop its outstanding credits; the buffer is + // retained so the next acquire continues mid-stream. Because delivery is + // credit-driven, any chunk the pump reads after this point simply stays + // buffered (the paused turn never credited it), so nothing is pushed at the + // detaching socket. The provider side closes its WS end independently. + this.consumer = null; + this.credits = 0; + this.touch(); + } + + /** Forcibly tear down the upstream connection and clear all state. Idempotent. */ + async discard(reason: string): Promise { + this.done = true; + try { this.consumer?.close(1000, reason); } catch { /* already closing */ } + this.consumer = null; + this.credits = 0; + const reader = this.reader; + this.reader = null; + if (reader) { try { await reader.cancel(reason); } catch { /* already done */ } } + const held = this.held; + this.held = null; + if (held) { try { await held.close(); } catch { /* idempotent */ } } + this.buffer = []; + this.bufferHead = 0; + this.bufferedBytes = 0; + this.started = false; + try { await this.ctx.storage.deleteAlarm(); } catch { /* no alarm set */ } + } + + // Idle eviction. Re-arms while activity is recent; otherwise tears the + // session down so an abandoned conversation — including one whose consumer + // is still attached but has gone silent — does not pin the outbound socket + // to the 15-minute runtime cap. + async alarm(): Promise { + const idleMs = Date.now() - this.lastActivityAt; + if (idleMs >= this.idleTimeoutMs) { + await this.discard('idle timeout'); + return; + } + await this.ctx.storage.setAlarm(Date.now() + this.idleTimeoutMs); + } + + private touch(): void { + this.lastActivityAt = Date.now(); + } + + // Hibernation close hooks (mirrors BroadcastDO): complete the close + // handshake from the actor side so a consumer never sees a 1006 abnormal + // closure, and drop our reference to the gone socket. + async webSocketClose(ws: WebSocket, code: number, reason: string, _wasClean: boolean): Promise { + if (this.consumer === ws) this.consumer = null; + try { ws.close(code, reason); } catch { /* already closed */ } + } + + async webSocketError(ws: WebSocket, _err: unknown): Promise { + if (this.consumer === ws) this.consumer = null; + } +} diff --git a/apps/platform-cloudflare/src/durable-http-session-do_test.ts b/apps/platform-cloudflare/src/durable-http-session-do_test.ts new file mode 100644 index 000000000..d51f30dda --- /dev/null +++ b/apps/platform-cloudflare/src/durable-http-session-do_test.ts @@ -0,0 +1,252 @@ +import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; + +// vi.mock factories are hoisted above module init, so the mock fns + the +// control state they read must live in vi.hoisted (also hoisted) rather than +// in plain top-level consts. +// +// socket-dial is mocked so this test never imports `cloudflare:sockets` (the +// vitest config only aliases `cloudflare:workers`). fetchOnStream is mocked so +// we drive the upstream response body directly — the DO's job under test is the +// session / buffer / consumer plumbing, not HTTP parsing. +const h = vi.hoisted(() => { + const ctl = { + bodyController: null as ReadableStreamDefaultController | null, + status: 200, + headers: { 'x-test': 'hi' } as Record, + hasBody: true, + }; + const socketClose = vi.fn(async () => {}); + const connect = vi.fn(async () => ({ + readable: new ReadableStream(), + writable: new WritableStream(), + close: socketClose, + })); + const fetchOnStream = vi.fn(async () => { + const body = ctl.hasBody + ? new ReadableStream({ start(c) { ctl.bodyController = c; } }) + : null; + return new Response(body, { status: ctl.status, headers: ctl.headers }); + }); + return { ctl, socketClose, connect, fetchOnStream }; +}); +vi.mock('./socket-dial.ts', () => ({ cloudflareSocketDial: { connect: h.connect } })); +vi.mock('@floway-dev/http', () => ({ fetchOnStream: h.fetchOnStream })); + +import { DurableHttpSessionDO } from './durable-http-session-do.ts'; + +// Minimal CF runtime surface. WebSocketPair / WebSocket are workerd globals; +// stub just enough for the consumer-attach path. +class FakeWebSocket { + readonly sent: Uint8Array[] = []; + readonly listeners = new Map void>>(); + closed: { code: number; reason: string } | null = null; + accept(): void {} + send(data: Uint8Array): void { this.sent.push(data); } + close(code = 1000, reason = ''): void { + if (this.closed) return; + this.closed = { code, reason }; + for (const fn of this.listeners.get('close') ?? []) fn({ code, reason }); + } + addEventListener(type: string, fn: (ev: unknown) => void): void { + if (!this.listeners.has(type)) this.listeners.set(type, new Set()); + this.listeners.get(type)!.add(fn); + } + // Test helper: simulate the broker sending one credit ("send me one chunk"). + // The DO's message listener ignores the payload, so any data works. + credit(): void { + for (const fn of this.listeners.get('message') ?? []) fn({ data: new Uint8Array([1]) }); + } +} + +const createdPairs: [FakeWebSocket, FakeWebSocket][] = []; + +class FakeAlarmStorage { + setAlarmCalls = 0; + deleteAlarmCalls = 0; + async setAlarm(): Promise { this.setAlarmCalls++; } + async deleteAlarm(): Promise { this.deleteAlarmCalls++; } +} + +class FakeState { + readonly accepted: WebSocket[] = []; + readonly storage = new FakeAlarmStorage(); + acceptWebSocket(ws: WebSocket): void { this.accepted.push(ws); } + getWebSockets(): WebSocket[] { return this.accepted; } +} + +const flushMicrotasks = (): Promise => new Promise(resolve => setTimeout(resolve, 0)); +const POST_INIT = { method: 'POST' as const, url: 'https://api2.cursor.sh/RunSSE', headers: {} }; + +// CF lets `new Response(null, { status: 101, webSocket })` carry a hibernation +// upgrade; Node's Response refuses status 101. Shim only that case so the DO's +// fetch() upgrade path runs under Node; everything else delegates to the real +// Response (the fetchOnStream mock needs a real streaming body). +const RealResponse = globalThis.Response; +function ShimResponse(body?: BodyInit | null, init?: ResponseInit & { webSocket?: unknown }): Response { + if (init?.status === 101) { + return { status: 101, webSocket: init.webSocket, body: null, headers: new Headers() } as unknown as Response; + } + return new RealResponse(body ?? null, init); +} + +beforeEach(() => { + h.ctl.bodyController = null; + h.ctl.status = 200; + h.ctl.headers = { 'x-test': 'hi' }; + h.ctl.hasBody = true; + createdPairs.length = 0; + h.connect.mockClear(); + h.socketClose.mockClear(); + h.fetchOnStream.mockClear(); + (globalThis as { Response: unknown }).Response = ShimResponse; + (globalThis as { WebSocketPair?: unknown }).WebSocketPair = class { + constructor() { + const pair: [FakeWebSocket, FakeWebSocket] = [new FakeWebSocket(), new FakeWebSocket()]; + createdPairs.push(pair); + return pair as unknown as FakeWebSocket; + } + }; +}); + +afterEach(() => { + (globalThis as { Response: unknown }).Response = RealResponse; + delete (globalThis as { WebSocketPair?: unknown }).WebSocketPair; +}); + +const makeDO = (): { actor: DurableHttpSessionDO; state: FakeState } => { + const state = new FakeState(); + const actor = new DurableHttpSessionDO(state as unknown as DurableObjectState, {}); + return { actor, state }; +}; + +describe('DurableHttpSessionDO.queryOrStart', () => { + test('miss + init=null returns null without dialing', async () => { + const { actor } = makeDO(); + expect(await actor.queryOrStart(null, 1000)).toBeNull(); + expect(h.connect).not.toHaveBeenCalled(); + }); + + test('miss + init dials, runs the request, returns status/headers, arms the alarm', async () => { + const { actor, state } = makeDO(); + const meta = await actor.queryOrStart(POST_INIT, 1000); + expect(meta).toEqual({ status: 200, headers: [['x-test', 'hi']] }); + expect(h.connect).toHaveBeenCalledTimes(1); + expect(h.connect).toHaveBeenCalledWith('api2.cursor.sh', 443, { tls: true }); + expect(state.storage.setAlarmCalls).toBe(1); + }); + + test('hit (already started) returns meta without dialing again', async () => { + const { actor } = makeDO(); + await actor.queryOrStart(POST_INIT, 1000); + h.connect.mockClear(); + const meta = await actor.queryOrStart(POST_INIT, 1000); + expect(meta).toEqual({ status: 200, headers: [['x-test', 'hi']] }); + expect(h.connect).not.toHaveBeenCalled(); + }); + + test('upstream with no body discards and throws', async () => { + h.ctl.hasBody = false; + const { actor } = makeDO(); + await expect(actor.queryOrStart(POST_INIT, 1000)).rejects.toThrow('returned no body'); + expect(h.socketClose).toHaveBeenCalled(); + }); +}); + +describe('DurableHttpSessionDO body channel + lifecycle', () => { + test('fetch before start returns 409', async () => { + const { actor } = makeDO(); + const resp = await actor.fetch(new Request('https://durable-http.do/body')); + expect(resp.status).toBe(409); + }); + + test('fetch after start upgrades to 101 and delivers one chunk per credit', async () => { + const { actor } = makeDO(); + await actor.queryOrStart(POST_INIT, 1000); + + h.ctl.bodyController!.enqueue(new Uint8Array([1, 2])); + await flushMicrotasks(); + + const resp = await actor.fetch(new Request('https://durable-http.do/body')); + expect(resp.status).toBe(101); + const server = createdPairs[0]![1]; + // Credit-based backpressure: nothing is sent until the consumer requests it. + expect(server.sent).toEqual([]); + + server.credit(); + expect(server.sent.flatMap(c => Array.from(c))).toEqual([1, 2]); + + // A live chunk stays buffered until the next credit. + h.ctl.bodyController!.enqueue(new Uint8Array([3])); + await flushMicrotasks(); + expect(server.sent.flatMap(c => Array.from(c))).toEqual([1, 2]); + server.credit(); + expect(server.sent.flatMap(c => Array.from(c))).toEqual([1, 2, 3]); + }); + + test('upstream end closes the consumer socket once a credit is waiting', async () => { + const { actor } = makeDO(); + await actor.queryOrStart(POST_INIT, 1000); + await actor.fetch(new Request('https://durable-http.do/body')); + const server = createdPairs[0]![1]; + h.ctl.bodyController!.close(); + await flushMicrotasks(); + // End-of-stream is delivered on a pull, not pushed: no credit → no close. + expect(server.closed).toBeNull(); + server.credit(); + expect(server.closed).not.toBeNull(); + }); + + test('discard cancels reader, closes the socket, deletes the alarm, resets to miss', async () => { + const { actor, state } = makeDO(); + await actor.queryOrStart(POST_INIT, 1000); + await actor.discard('framing error'); + expect(h.socketClose).toHaveBeenCalled(); + expect(state.storage.deleteAlarmCalls).toBe(1); + expect(await actor.queryOrStart(null, 1000)).toBeNull(); + }); + + test('buffer overflow with no consumer discards the session', async () => { + const { actor } = makeDO(); + await actor.queryOrStart(POST_INIT, 1000); + h.ctl.bodyController!.enqueue(new Uint8Array(1_100_000)); + await flushMicrotasks(); + expect(h.socketClose).toHaveBeenCalled(); + }); +}); + +describe('DurableHttpSessionDO.alarm', () => { + test('re-arms (does not discard) while activity is recent', async () => { + const { actor, state } = makeDO(); + await actor.queryOrStart(POST_INIT, 1000); + state.storage.setAlarmCalls = 0; + await actor.alarm(); + expect(state.storage.setAlarmCalls).toBe(1); + expect(h.socketClose).not.toHaveBeenCalled(); + }); + + test('re-arms while a consumer is attached', async () => { + const { actor, state } = makeDO(); + await actor.queryOrStart(POST_INIT, 1000); + await actor.fetch(new Request('https://durable-http.do/body')); + state.storage.setAlarmCalls = 0; + await actor.alarm(); + expect(state.storage.setAlarmCalls).toBe(1); + expect(h.socketClose).not.toHaveBeenCalled(); + }); + + test('evicts an attached-but-silent consumer past the idle timeout', async () => { + // A consumer that opened the WS, received bytes, then paused reading past + // the idle timeout must not pin the DO to the 15-minute runtime cap; the + // alarm has to discard regardless of consumer presence. + const { actor, state } = makeDO(); + await actor.queryOrStart(POST_INIT, 1); + await actor.fetch(new Request('https://durable-http.do/body')); + await new Promise(resolve => setTimeout(resolve, 5)); + state.storage.setAlarmCalls = 0; + state.storage.deleteAlarmCalls = 0; + await actor.alarm(); + expect(h.socketClose).toHaveBeenCalled(); + expect(state.storage.deleteAlarmCalls).toBe(1); + expect(state.storage.setAlarmCalls).toBe(0); + }); +}); diff --git a/apps/platform-node/package.json b/apps/platform-node/package.json index fe7005eff..3514ca360 100644 --- a/apps/platform-node/package.json +++ b/apps/platform-node/package.json @@ -14,6 +14,7 @@ "@floway-dev/gateway": "workspace:*", "@floway-dev/http": "workspace:*", "@floway-dev/platform": "workspace:*", + "@floway-dev/proxy": "workspace:*", "@floway-dev/protocols": "workspace:*", "@hono/node-server": "^2.0.4", "hono": "^4", diff --git a/apps/platform-node/src/bootstrap.ts b/apps/platform-node/src/bootstrap.ts index ce297de0f..ae81ba9a7 100644 --- a/apps/platform-node/src/bootstrap.ts +++ b/apps/platform-node/src/bootstrap.ts @@ -1,5 +1,6 @@ import { EventTargetChannelBroker } from './event-target-channel-broker.ts'; import { FsFileProvider } from './fs-file-provider.ts'; +import { InProcessDurableHttpSession } from './in-process-durable-http-session.ts'; import { createNodeSqliteDatabase } from './node-sqlite-database.ts'; import { createSharpImageProcessor } from './sharp-image-processor.ts'; import { nodeSocketDial } from './socket-dial.ts'; @@ -12,6 +13,7 @@ import { addTrustedRootCAs } from '@floway-dev/http'; import { getEnvOptional, IMAGE_CACHE_POLICY, + initDurableHttpSession, initEnv, initFileProvider, initImageCacheStore, @@ -37,5 +39,6 @@ export const bootstrapNodePlatform = (): { db: SqlDatabase } => { initImageProcessor(createSharpImageProcessor()); initDumpStore(new FileDumpStore(db, files)); initDumpBroker(new EventTargetChannelBroker(dumpCodec)); + initDurableHttpSession(new InProcessDurableHttpSession()); return { db }; }; diff --git a/apps/platform-node/src/in-process-durable-http-session.ts b/apps/platform-node/src/in-process-durable-http-session.ts new file mode 100644 index 000000000..7849a3826 --- /dev/null +++ b/apps/platform-node/src/in-process-durable-http-session.ts @@ -0,0 +1,244 @@ +import { + getSocketDial, + normalizeDialHost, + type DurableHttpSession, + type DurableHttpSessionAcquireOptions, + type DurableHttpSessionHandle, + type DurableHttpSessionInit, +} from '@floway-dev/platform'; +import { runProxiedRequest, type ProxyConfig } from '@floway-dev/proxy'; + +const DEFAULT_IDLE_TIMEOUT_MS = 5 * 60 * 1000; + +// Dial the upstream: direct via globalThis.fetch (undici), or — when the init +// carries proxies — through them in order via runProxiedRequest (which streams, +// unlike the gateway's buffered proxy Fetcher), falling back to the next on a +// dial failure. +const dialUpstream = async (init: DurableHttpSessionInit): Promise => { + const proxies = (init.proxies ?? []) as ProxyConfig[]; + if (proxies.length === 0) { + return await globalThis.fetch(init.url, { + method: init.method, + headers: init.headers, + body: init.body ? (init.body as BodyInit) : null, + }); + } + const u = new URL(init.url); + const target = { host: normalizeDialHost(u.hostname), port: u.port ? Number(u.port) : (u.protocol === 'https:' ? 443 : 80), tls: u.protocol === 'https:' }; + const request = { method: init.method, path: `${u.pathname}${u.search}`, headers: init.headers, body: init.body }; + const socketDial = getSocketDial(); + let lastErr: unknown; + for (const config of proxies) { + try { + return await runProxiedRequest(config, target, request, { socketDial }); + } catch (err) { + lastErr = err; + } + } + throw lastErr ?? new Error('all proxies failed for DurableHttpSession dial'); +}; + +interface Entry { + response: Response; + reader: ReadableStreamDefaultReader; + /** Buffered chunks not yet consumed by the current handle. */ + buffer: Uint8Array[]; + /** Resolves the next dequeue when a chunk arrives or the stream ends. */ + waiter: { resolve: (v: { done: boolean; value?: Uint8Array }) => void } | null; + done: boolean; + error: unknown; + inFlight: Promise | null; + idleTimer: NodeJS.Timeout | null; + lastActivityAt: number; +} + +export class InProcessDurableHttpSession implements DurableHttpSession { + private readonly entries = new Map(); + + async acquire( + sessionKey: string, + init: DurableHttpSessionInit | null, + opts?: DurableHttpSessionAcquireOptions, + ): Promise { + const idleTimeoutMs = opts?.idleTimeoutMs ?? DEFAULT_IDLE_TIMEOUT_MS; + + const existingInFlight = this.entries.get(sessionKey)?.inFlight; + if (existingInFlight) await existingInFlight.catch(() => {}); + + let entry = this.entries.get(sessionKey); + + if (!entry) { + if (init === null) return null; + const lock = (async (): Promise => { + const response = await dialUpstream(init); + if (!response.body) { + throw new Error(`InProcessDurableHttpSession: ${init.method} ${init.url} returned no body`); + } + const reader = response.body.getReader(); + const e: Entry = { + response, + reader, + buffer: [], + waiter: null, + done: false, + error: null, + inFlight: null, + idleTimer: null, + lastActivityAt: Date.now(), + }; + this.startPump(e); + return e; + })(); + const placeholder: Entry = { + response: undefined as unknown as Response, + reader: undefined as unknown as ReadableStreamDefaultReader, + buffer: [], + waiter: null, + done: false, + error: null, + inFlight: lock, + idleTimer: null, + lastActivityAt: Date.now(), + }; + this.entries.set(sessionKey, placeholder); + try { + entry = await lock; + this.entries.set(sessionKey, entry); + } catch (err) { + this.entries.delete(sessionKey); + throw err; + } + } + + entry.lastActivityAt = Date.now(); + this.armIdleTimer(sessionKey, entry, idleTimeoutMs); + return this.makeHandle(sessionKey, entry, opts?.signal); + } + + private startPump(entry: Entry): void { + const pump = async (): Promise => { + try { + while (true) { + const { done, value } = await entry.reader.read(); + if (done) { + entry.done = true; + if (entry.waiter) { + entry.waiter.resolve({ done: true }); + entry.waiter = null; + } + return; + } + if (value) { + if (entry.waiter) { + entry.waiter.resolve({ done: false, value }); + entry.waiter = null; + } else { + entry.buffer.push(value); + } + } + } + } catch (err) { + entry.error = err; + entry.done = true; + if (entry.waiter) { + entry.waiter.resolve({ done: true }); + entry.waiter = null; + } + } + }; + void pump(); + } + + private dequeue(entry: Entry): Promise<{ done: boolean; value?: Uint8Array }> { + if (entry.buffer.length > 0) { + return Promise.resolve({ done: false, value: entry.buffer.shift()! }); + } + if (entry.done) { + return Promise.resolve({ done: true }); + } + return new Promise(resolve => { entry.waiter = { resolve }; }); + } + + private armIdleTimer(sessionKey: string, entry: Entry, idleTimeoutMs: number): void { + if (entry.idleTimer) clearTimeout(entry.idleTimer); + entry.idleTimer = setTimeout(() => this.evict(sessionKey, 'idle timeout'), idleTimeoutMs); + entry.idleTimer.unref?.(); + } + + private evict(sessionKey: string, _reason: string): void { + const entry = this.entries.get(sessionKey); + if (!entry) return; + this.entries.delete(sessionKey); + if (entry.idleTimer) clearTimeout(entry.idleTimer); + try { entry.reader.releaseLock(); } catch { /* already released */ } + void entry.response.body?.cancel(_reason).catch(() => {}); + } + + private makeHandle( + sessionKey: string, + entry: Entry, + signal: AbortSignal | undefined, + ): DurableHttpSessionHandle { + const self = this; + let released = false; + const onAbort = (): void => { released = true; }; + if (signal) { + if (signal.aborted) released = true; + else signal.addEventListener('abort', onAbort, { once: true }); + } + + // highWaterMark 0: the stream only pulls to satisfy an active read() — it + // never speculatively reads ahead. Without this, the default hWM of 1 + // makes the stream pull a chunk into its own internal queue (or park a + // waiter) right after the consumer's last read, so during a mid-turn + // pause a chunk the upstream sends next can land in the about-to-be- + // discarded view instead of the shared entry buffer — lost across the + // turn handoff, wedging the upstream. Pulling 1:1 with reads mirrors + // reading the socket directly. + const body = new ReadableStream({ + async pull(controller): Promise { + if (released) { controller.close(); return; } + const result = await self.dequeue(entry); + if (released || result.done) { controller.close(); return; } + if (result.value) controller.enqueue(result.value); + }, + cancel(): void { + released = true; + if (signal) signal.removeEventListener('abort', onAbort); + }, + }, { highWaterMark: 0 }); + + return { + status: entry.response.status, + headers: entry.response.headers, + body, + async release(): Promise { + released = true; + if (signal) signal.removeEventListener('abort', onAbort); + // Abandon any dequeue this view left pending. The stream pulls ahead, so + // at the pause point view A is usually parked on an empty-buffer waiter. + // If we leave it, the pump delivers the next chunk (the first byte the + // upstream sends after the tool result) to this dead stream — it is lost + // and never acked, wedging the upstream. Close view A's pull and null the + // slot so the next chunk buffers for the next acquirer instead. + if (entry.waiter) { + entry.waiter.resolve({ done: true }); + entry.waiter = null; + } + }, + async discard(reason: string): Promise { + released = true; + if (signal) signal.removeEventListener('abort', onAbort); + self.evict(sessionKey, reason); + }, + }; + } + + evictAllForTesting(): void { + for (const key of [...this.entries.keys()]) this.evict(key, 'test reset'); + } + + sizeForTesting(): number { + return this.entries.size; + } +} diff --git a/apps/platform-node/src/in-process-durable-http-session_test.ts b/apps/platform-node/src/in-process-durable-http-session_test.ts new file mode 100644 index 000000000..37224b19a --- /dev/null +++ b/apps/platform-node/src/in-process-durable-http-session_test.ts @@ -0,0 +1,200 @@ +import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; + +import { InProcessDurableHttpSession } from './in-process-durable-http-session.ts'; + +const mockFetch = (factory: () => Response | Promise): typeof globalThis.fetch => + vi.fn(async () => await factory()) as unknown as typeof globalThis.fetch; + +const collectBody = async (body: ReadableStream): Promise => { + const chunks: Uint8Array[] = []; + const reader = body.getReader(); + while (true) { + const { done, value } = await reader.read(); + if (done) break; + if (value) chunks.push(value); + } + const out = new Uint8Array(chunks.reduce((s, c) => s + c.length, 0)); + let off = 0; + for (const c of chunks) { out.set(c, off); off += c.length; } + return out; +}; + +const makeResponse = (status: number, headers: Record, chunks: Uint8Array[]): Response => { + const stream = new ReadableStream({ + start(controller) { + for (const c of chunks) controller.enqueue(c); + controller.close(); + }, + }); + return new Response(stream, { status, headers }); +}; + +let originalFetch: typeof globalThis.fetch; + +beforeEach(() => { + originalFetch = globalThis.fetch; +}); + +afterEach(() => { + globalThis.fetch = originalFetch; +}); + +describe('InProcessDurableHttpSession.acquire', () => { + test('returns null on miss when init is null', async () => { + const broker = new InProcessDurableHttpSession(); + const handle = await broker.acquire('k', null); + expect(handle).toBeNull(); + expect(broker.sizeForTesting()).toBe(0); + }); + + test('seeds a new entry on miss + init non-null and exposes status/headers/body', async () => { + globalThis.fetch = mockFetch(() => makeResponse(200, { 'x-test': 'hi' }, [new Uint8Array([1, 2, 3])])); + const broker = new InProcessDurableHttpSession(); + const handle = await broker.acquire('k', { method: 'POST', url: 'https://x', headers: {} }); + + expect(handle).not.toBeNull(); + expect(handle!.status).toBe(200); + expect(handle!.headers.get('x-test')).toBe('hi'); + expect(Array.from(await collectBody(handle!.body))).toEqual([1, 2, 3]); + expect(broker.sizeForTesting()).toBe(1); + }); + + test('hit returns a handle to the cached entry on subsequent acquire(key, null)', async () => { + let calls = 0; + globalThis.fetch = mockFetch(() => { + calls++; + return makeResponse(201, {}, [new Uint8Array([1])]); + }); + const broker = new InProcessDurableHttpSession(); + const h1 = await broker.acquire('k', { method: 'POST', url: 'https://x', headers: {} }); + expect(h1!.status).toBe(201); + const h2 = await broker.acquire('k', null); + expect(h2).not.toBeNull(); + expect(h2!.status).toBe(201); + expect(calls).toBe(1); // upstream fetch called once + }); + + test('hit + init non-null ignores init (existing session wins)', async () => { + globalThis.fetch = mockFetch(() => makeResponse(200, {}, [])); + const broker = new InProcessDurableHttpSession(); + await broker.acquire('k', { method: 'POST', url: 'https://x', headers: {} }); + const fetchCallsBefore = (globalThis.fetch as unknown as { mock: { calls: unknown[] } }).mock.calls.length; + await broker.acquire('k', { method: 'POST', url: 'https://other', headers: {} }); + const fetchCallsAfter = (globalThis.fetch as unknown as { mock: { calls: unknown[] } }).mock.calls.length; + expect(fetchCallsAfter).toBe(fetchCallsBefore); // no second upstream fetch + }); + + test('discard evicts the entry so the next acquire(key, null) misses', async () => { + globalThis.fetch = mockFetch(() => makeResponse(200, {}, [])); + const broker = new InProcessDurableHttpSession(); + const handle = await broker.acquire('k', { method: 'POST', url: 'https://x', headers: {} }); + await handle!.discard('done'); + const next = await broker.acquire('k', null); + expect(next).toBeNull(); + expect(broker.sizeForTesting()).toBe(0); + }); + + test('idle TTL evicts the entry after the configured timeout', async () => { + vi.useFakeTimers(); + try { + globalThis.fetch = mockFetch(() => makeResponse(200, {}, [])); + const broker = new InProcessDurableHttpSession(); + await broker.acquire('k', { method: 'POST', url: 'https://x', headers: {} }, { idleTimeoutMs: 1000 }); + expect(broker.sizeForTesting()).toBe(1); + vi.advanceTimersByTime(1500); + // microtask flush so the timer callback's evict runs + await Promise.resolve(); + expect(broker.sizeForTesting()).toBe(0); + } finally { + vi.useRealTimers(); + } + }); + + test('concurrent acquires for the same key trigger only one upstream fetch', async () => { + let calls = 0; + globalThis.fetch = mockFetch(async () => { + calls++; + // simulate slow upstream so two acquires race + await new Promise(resolve => setTimeout(resolve, 50)); + return makeResponse(200, {}, []); + }); + const broker = new InProcessDurableHttpSession(); + const init = { method: 'POST' as const, url: 'https://x', headers: {} }; + const [a, b] = await Promise.all([ + broker.acquire('k', init), + broker.acquire('k', init), + ]); + expect(a).not.toBeNull(); + expect(b).not.toBeNull(); + expect(calls).toBe(1); // dedup'd + }); + + test('release is a no-op at the session level (next acquire still hits)', async () => { + globalThis.fetch = mockFetch(() => makeResponse(200, {}, [])); + const broker = new InProcessDurableHttpSession(); + const handle = await broker.acquire('k', { method: 'POST', url: 'https://x', headers: {} }); + await handle!.release(); + const next = await broker.acquire('k', null); + expect(next).not.toBeNull(); + }); + + test('upstream returning no body throws on create', async () => { + globalThis.fetch = mockFetch(() => new Response(null, { status: 204 })); + const broker = new InProcessDurableHttpSession(); + await expect( + broker.acquire('k', { method: 'POST', url: 'https://x', headers: {} }), + ).rejects.toThrow('returned no body'); + expect(broker.sizeForTesting()).toBe(0); + }); +}); + +describe('InProcessDurableHttpSession — Response GC anchor', () => { + // This test verifies the architectural fix: the entry holds the Response + // object itself, not just response.body. Without anchoring, V8 GC of the + // Response causes "Response object has been garbage collected" errors on + // subsequent reads. Running with --expose-gc lets us force the collection + // to assert the entry survives. + // + // Skipped when --expose-gc was not passed; the test still serves as + // documentation and runs in CI where the harness exposes gc. + const gc = (globalThis as { gc?: () => void }).gc; + const maybeIt = gc ? test : test.skip; + + maybeIt('survives forced GC between writes and a subsequent acquire', async () => { + // Build an upstream that streams chunks asynchronously so the body is + // not synchronously closed before we force GC. + let resolveSecondChunk: ((c: Uint8Array | null) => void) | null = null; + const upstreamBody = new ReadableStream({ + start(controller) { + controller.enqueue(new Uint8Array([1, 2, 3])); + resolveSecondChunk = c => { + if (c) controller.enqueue(c); + controller.close(); + }; + }, + }); + globalThis.fetch = mockFetch(() => new Response(upstreamBody, { status: 200 })); + + const broker = new InProcessDurableHttpSession(); + let first: { body: ReadableStream } | null = + await broker.acquire('k', { method: 'POST', url: 'https://x', headers: {} }); + const firstReader = first!.body.getReader(); + const firstChunk = await firstReader.read(); + expect(Array.from(firstChunk.value!)).toEqual([1, 2, 3]); + firstReader.releaseLock(); + + // Drop the local handle and force GC. If the entry weren't anchoring + // the Response, the next acquire would fail when reading. + first = null; + gc!(); + gc!(); + await new Promise(resolve => setTimeout(resolve, 0)); + + const second = await broker.acquire('k', null); + expect(second).not.toBeNull(); + const secondReader = second!.body.getReader(); + resolveSecondChunk!(new Uint8Array([9])); + const secondChunk = await secondReader.read(); + expect(Array.from(secondChunk.value!)).toEqual([9]); + }); +}); diff --git a/apps/web/src/api/types.ts b/apps/web/src/api/types.ts index f713e28dd..3bf74e118 100644 --- a/apps/web/src/api/types.ts +++ b/apps/web/src/api/types.ts @@ -25,7 +25,7 @@ export type { PublicModel, PublicModelLimits, }; -export type UpstreamProviderKind = 'custom' | 'azure' | 'copilot' | 'codex' | 'claude-code' | 'ollama'; +export type UpstreamProviderKind = 'custom' | 'azure' | 'copilot' | 'codex' | 'claude-code' | 'cursor' | 'ollama'; export interface ProxyFallbackEntry { id: string; @@ -150,6 +150,46 @@ export interface CodexUpstreamState { accounts: CodexAccountCredentialState[]; } +export interface CursorAccountIdentity { + email: string; + userId: string; +} + +export interface CursorUpstreamConfig { + accounts: [CursorAccountIdentity]; + // Operator toggle: send every request in Cursor Max Mode (larger context + // window, higher usage cost). Absent/false = normal mode. + maxMode?: boolean; + // Operator toggle: expose Cursor Tab (StreamCpp) as an OpenAI /v1/completions + // edit-prediction model. `model` is the cpp model name sent upstream. + tabCompletion?: { + enabled: boolean; + model?: string; + }; + // Ghost/privacy mode toggle sent as the x-ghost-mode data-plane header. + // Absent = privacy on (default). Editable via the config panel. + privacyMode?: boolean; +} + +export interface CursorAccessTokenState { + expiresAt: number; + refreshedAt: string; +} + +export interface CursorAccountCredentialState { + userId: string; + state: 'active' | 'session_terminated' | 'refresh_failed'; + state_message?: string; + state_updated_at: string; + refresh_token_set: boolean; + accessToken: CursorAccessTokenState | null; + quotaSnapshot?: unknown; +} + +export interface CursorUpstreamState { + accounts: CursorAccountCredentialState[]; +} + export interface CodexQuotaSnapshot { observed_at: string; active_limit?: string; @@ -304,6 +344,7 @@ export type UpstreamRecord = | (UpstreamRecordBase & { kind: 'copilot'; config: CopilotUpstreamConfig; state: CopilotUpstreamState | null }) | (UpstreamRecordBase & { kind: 'codex'; config: CodexUpstreamConfig; state: CodexUpstreamState | null; codex_quota?: CodexQuotaSnapshot | null }) | (UpstreamRecordBase & { kind: 'claude-code'; config: ClaudeCodeUpstreamConfig; state: ClaudeCodeUpstreamState | null }) + | (UpstreamRecordBase & { kind: 'cursor'; config: CursorUpstreamConfig; state: CursorUpstreamState | null }) | (UpstreamRecordBase & { kind: 'ollama'; config: OllamaUpstreamConfig; state: null }); export interface FlagDef { @@ -356,6 +397,18 @@ export interface CopilotQuotaSnapshot { }; } +// Mirror of the provider-cursor CursorDashboardUsage shape. Fetched from +// GET /api/upstreams/:id/cursor/quota on the dashboard's Cursor upstream page; +// see packages/provider-cursor/src/quota.ts for field semantics. +export interface CursorDashboardUsage { + limitCents: number | null; + totalSpendCents: number; + autoPercentUsed: number; + apiPercentUsed: number; + totalPercentUsed: number; + billingCycleEndMs: number | null; +} + export interface DeviceFlowStart { user_code: string; verification_uri: string; diff --git a/apps/web/src/components/dump/RequestList.vue b/apps/web/src/components/dump/RequestList.vue index 11f15da04..4fddc65d6 100644 --- a/apps/web/src/components/dump/RequestList.vue +++ b/apps/web/src/components/dump/RequestList.vue @@ -77,6 +77,7 @@ const upstreamKindTextClass = (kind: string): string => { case 'azure': return 'text-accent-emerald'; case 'custom': return 'text-accent-amber'; case 'ollama': return 'text-accent-rose'; + case 'cursor': return 'text-accent-violet'; default: return 'text-gray-500'; } }; diff --git a/apps/web/src/components/settings/UpstreamRow.vue b/apps/web/src/components/settings/UpstreamRow.vue index e00de03ee..bef26e082 100644 --- a/apps/web/src/components/settings/UpstreamRow.vue +++ b/apps/web/src/components/settings/UpstreamRow.vue @@ -48,6 +48,10 @@ const subtitle = computed(() => { return subscription ? `${label} · ${subscription}` : label; } case 'ollama': return u.config.baseUrl ?? 'Ollama endpoint'; + case 'cursor': { + const account = u.config.accounts[0]; + return account.email; + } } return assertNever(u); }); diff --git a/apps/web/src/components/upstream-edit/CursorAccountCard.vue b/apps/web/src/components/upstream-edit/CursorAccountCard.vue new file mode 100644 index 000000000..9a4bfafb1 --- /dev/null +++ b/apps/web/src/components/upstream-edit/CursorAccountCard.vue @@ -0,0 +1,125 @@ + + + diff --git a/apps/web/src/components/upstream-edit/CursorConfigPanel.vue b/apps/web/src/components/upstream-edit/CursorConfigPanel.vue new file mode 100644 index 000000000..e6b055248 --- /dev/null +++ b/apps/web/src/components/upstream-edit/CursorConfigPanel.vue @@ -0,0 +1,268 @@ + + + diff --git a/apps/web/src/components/upstream-edit/UpstreamConfigPanel.vue b/apps/web/src/components/upstream-edit/UpstreamConfigPanel.vue index 85d491760..a6b08c406 100644 --- a/apps/web/src/components/upstream-edit/UpstreamConfigPanel.vue +++ b/apps/web/src/components/upstream-edit/UpstreamConfigPanel.vue @@ -5,6 +5,7 @@ import AzureConfigPanel from './AzureConfigPanel.vue'; import ClaudeCodeConfigPanel from './ClaudeCodeConfigPanel.vue'; import CodexConfigPanel from './CodexConfigPanel.vue'; import CopilotConfigPanel from './CopilotConfigPanel.vue'; +import CursorConfigPanel from './CursorConfigPanel.vue'; import type { AzureDraft, CustomDraft, OllamaDraft } from './customConfig.ts'; import CustomConfigPanel from './CustomConfigPanel.vue'; import FlagOverridesEditor from './FlagOverridesEditor.vue'; @@ -12,7 +13,7 @@ import ModelPrefixEditor from './ModelPrefixEditor.vue'; import ModelsCacheStatus from './ModelsCacheStatus.vue'; import OllamaConfigPanel from './OllamaConfigPanel.vue'; import ProxyFallbackListPanel from './ProxyFallbackListPanel.vue'; -import type { CopilotQuotaSnapshot, FlagDef, ModelPrefixConfig, ProxyFallbackEntry, UpstreamProviderKind, UpstreamRecord } from '../../api/types.ts'; +import type { CopilotQuotaSnapshot, CursorDashboardUsage, FlagDef, ModelPrefixConfig, ProxyFallbackEntry, UpstreamProviderKind, UpstreamRecord } from '../../api/types.ts'; import { providerBadgeClass, providerMeta } from '../upstreams/provider-meta.ts'; import { Input, Switch, TagCombobox } from '@floway-dev/ui'; @@ -23,6 +24,7 @@ const disabledIds = defineModel('disabledIds', { required: true }); const customDraft = defineModel('custom', { required: true }); const azureDraft = defineModel('azure', { required: true }); const ollamaDraft = defineModel('ollama', { required: true }); +const cursorPrivacyMode = defineModel('cursorPrivacyMode', { required: true }); const proxyFallbackList = defineModel('proxyFallbackList', { required: true }); const modelPrefix = defineModel('modelPrefix', { required: true }); @@ -38,6 +40,8 @@ type CommonConfigPanelProps = { availableModelItems: { value: string; label: string }[]; initialCopilotQuota?: CopilotQuotaSnapshot | null; initialCopilotQuotaError?: string | null; + initialCursorQuota?: CursorDashboardUsage | null; + initialCursorQuotaError?: string | null; // Live cache snapshot for the saved upstream. Null in create mode and for // Azure (which has no fetch step) — `ModelsCacheStatus` is rendered only // when this is provided. @@ -69,6 +73,7 @@ defineEmits<{ type CodexRecord = Extract; type ClaudeCodeRecord = Extract; type CopilotRecord = Extract; +type CursorRecord = Extract; type PanelMode = { mode: 'create'; record: null } | { mode: 'edit'; record: R }; const codexPanel = computed | null>(() => { @@ -83,6 +88,10 @@ const copilotPanel = computed | null>(() => { if (props.mode === 'create') return { mode: 'create', record: null }; return props.record.kind === 'copilot' ? { mode: 'edit', record: props.record } : null; }); +const cursorPanel = computed | null>(() => { + if (props.mode === 'create') return { mode: 'create', record: null }; + return props.record.kind === 'cursor' ? { mode: 'edit', record: props.record } : null; +}); // Intrinsic floor for the aside: smallest height at which every // non-flag-editor section is fully laid out AND the flag editor still has @@ -145,7 +154,7 @@ onBeforeUnmount(() => floorObserver?.disconnect());
-
+
@@ -225,6 +234,18 @@ onBeforeUnmount(() => floorObserver?.disconnect()); />
+
+ +
+
diff --git a/apps/web/src/components/upstream-edit/UpstreamEditPage.vue b/apps/web/src/components/upstream-edit/UpstreamEditPage.vue index b28967b44..c83021968 100644 --- a/apps/web/src/components/upstream-edit/UpstreamEditPage.vue +++ b/apps/web/src/components/upstream-edit/UpstreamEditPage.vue @@ -20,7 +20,7 @@ import { import ModelsPanel from './ModelsPanel.vue'; import UpstreamConfigPanel from './UpstreamConfigPanel.vue'; import { authFetch, callApi, useApi } from '../../api/client.ts'; -import type { CopilotQuotaSnapshot, CustomRawModel, FlagDef, ModelEndpoints, ModelPrefixConfig, OllamaUpstreamConfig, ProxyFallbackEntry, UpstreamModelConfig, UpstreamProviderKind, UpstreamRecord } from '../../api/types.ts'; +import type { CopilotQuotaSnapshot, CursorDashboardUsage, CustomRawModel, FlagDef, ModelEndpoints, ModelPrefixConfig, OllamaUpstreamConfig, ProxyFallbackEntry, UpstreamModelConfig, UpstreamProviderKind, UpstreamRecord } from '../../api/types.ts'; import { useRuntimeInfo } from '../../composables/useRuntimeInfo.ts'; import { useUpstreamsStore } from '../../composables/useUpstreams.ts'; import { providerMeta } from '../upstreams/provider-meta.ts'; @@ -37,6 +37,8 @@ type CommonPageProps = { initialUpstreamModelsError?: string | null; initialCopilotQuota?: CopilotQuotaSnapshot | null; initialCopilotQuotaError?: string | null; + initialCursorQuota?: CursorDashboardUsage | null; + initialCursorQuotaError?: string | null; }; const props = defineProps< @@ -92,6 +94,10 @@ const modelPrefixInvalid = ref(false); const customDraft = ref(blankCustomDraft()); const azureDraft = ref(blankAzureDraft()); const ollamaDraft = ref(blankOllamaDraft()); +// Cursor privacy toggle persisted via the page Save button (maxMode / +// tabCompletion are edited inline in CursorConfigPanel). Absent on the +// record = privacy on. +const cursorPrivacyMode = ref(true); const upstreamModels = ref(props.initialUpstreamModels ?? []); const upstreamModelsError = ref(props.initialUpstreamModelsError ?? null); @@ -151,6 +157,8 @@ const seedFromRecord = (r: UpstreamRecord) => { apiKey: '', models: cfg.models ? (JSON.parse(JSON.stringify(cfg.models)) as UpstreamModelConfig[]) : [], }; + } else if (r.kind === 'cursor') { + cursorPrivacyMode.value = r.config.privacyMode ?? true; } }; @@ -388,6 +396,7 @@ const save = async () => { if (activeKind.value === 'custom') patch.config = buildCustomConfig(); else if (activeKind.value === 'azure') patch.config = buildAzureConfig(); else if (activeKind.value === 'ollama') patch.config = buildOllamaConfig(); + else if (activeKind.value === 'cursor') patch.config = { privacyMode: cursorPrivacyMode.value }; const { error } = await callApi( () => api.api.upstreams[':id'].$patch({ param: { id: props.record.id }, json: patch }), ); @@ -458,7 +467,7 @@ const autoForActive = computed(() => { const upstreamIdLabelForActive = computed(() => activeKind.value === 'azure' ? 'Deployment' : 'Upstream Model ID'); // Provider import panels (copilot/codex/claude-code) land the row themselves on create, so the page-level Save button stays hidden until they emit. -const showSaveButton = computed(() => props.mode === 'edit' || (activeKind.value !== 'copilot' && activeKind.value !== 'codex' && activeKind.value !== 'claude-code')); +const showSaveButton = computed(() => props.mode === 'edit' || (activeKind.value !== 'copilot' && activeKind.value !== 'codex' && activeKind.value !== 'claude-code' && activeKind.value !== 'cursor')); // The cache-status panel reads the row's `modelsCache` summary and offers a // force-refresh shortcut. Azure is the one provider whose catalog is pure @@ -561,6 +570,7 @@ const workbenchStyle = computed(() => ({ '--right-pane-h': `${Math.ceil(rightCon v-model:custom="customDraft" v-model:azure="azureDraft" v-model:ollama="ollamaDraft" + v-model:cursor-privacy-mode="cursorPrivacyMode" :flags="flags" :colo-aware="coloAware" :current-colo="currentColo" @@ -573,6 +583,8 @@ const workbenchStyle = computed(() => ({ '--right-pane-h': `${Math.ceil(rightCon :available-model-items="availableModelItems" :initial-copilot-quota="initialCopilotQuota" :initial-copilot-quota-error="initialCopilotQuotaError" + :initial-cursor-quota="initialCursorQuota" + :initial-cursor-quota-error="initialCursorQuotaError" :models-cache="showCacheStatus ? liveRecord!.modelsCache : null" :refreshing="refreshing" @fetch-models="fetchDraftModels" @@ -590,7 +602,7 @@ const workbenchStyle = computed(() => ({ '--right-pane-h': `${Math.ceil(rightCon :upstream-flag-overrides="flagOverrides" :flag-provider-kind="activeKind" :upstream-id-label="upstreamIdLabelForActive" - :read-only="activeKind === 'copilot' || activeKind === 'codex' || activeKind === 'claude-code'" + :read-only="activeKind === 'copilot' || activeKind === 'codex' || activeKind === 'claude-code' || activeKind === 'cursor'" :all-manual="activeKind === 'azure'" @update:invalid="v => modelsPanelInvalid = v" /> diff --git a/apps/web/src/components/upstreams/UpstreamPicker.vue b/apps/web/src/components/upstreams/UpstreamPicker.vue index 878290d43..360c4bde1 100644 --- a/apps/web/src/components/upstreams/UpstreamPicker.vue +++ b/apps/web/src/components/upstreams/UpstreamPicker.vue @@ -70,6 +70,7 @@ const providerMeta = (kind: UpstreamProviderKind | null): ProviderMeta => { case 'codex': return { tone: 'cyan', label: 'Codex' }; case 'claude-code': return { tone: 'rose', label: 'Claude Code' }; case 'ollama': return { tone: 'rose', label: 'Ollama' }; + case 'cursor': return { tone: 'cyan', label: 'Cursor' }; } return assertNever(kind); }; diff --git a/apps/web/src/components/upstreams/provider-meta.ts b/apps/web/src/components/upstreams/provider-meta.ts index 06c17d0fc..4395dd70e 100644 --- a/apps/web/src/components/upstreams/provider-meta.ts +++ b/apps/web/src/components/upstreams/provider-meta.ts @@ -59,6 +59,14 @@ export const PROVIDER_META: readonly ProviderMeta[] = [ defaultName: 'Claude Code', icon: 'i-simple-icons-claudecode', }, + { + kind: 'cursor', + label: 'Cursor', + subtitle: 'Cursor Pro / Business subscription', + tone: 'violet', + defaultName: 'Cursor', + icon: 'i-simple-icons-cursor', + }, { kind: 'ollama', label: 'Ollama', diff --git a/apps/web/src/pages/dashboard/upstreams/[id].vue b/apps/web/src/pages/dashboard/upstreams/[id].vue index 94e8a03ef..07678551d 100644 --- a/apps/web/src/pages/dashboard/upstreams/[id].vue +++ b/apps/web/src/pages/dashboard/upstreams/[id].vue @@ -3,7 +3,7 @@ import { defineBasicLoader } from 'unplugin-vue-router/data-loaders/basic'; import { useRouter } from 'vue-router'; import { callApi, useApi } from '../../../api/client.ts'; -import type { CopilotQuotaSnapshot, UpstreamModelConfig } from '../../../api/types.ts'; +import type { CopilotQuotaSnapshot, CursorDashboardUsage, UpstreamModelConfig } from '../../../api/types.ts'; import UpstreamEditPage from '../../../components/upstream-edit/UpstreamEditPage.vue'; import { useProxiesStore } from '../../../composables/useProxies.ts'; import { useRuntimeInfo } from '../../../composables/useRuntimeInfo.ts'; @@ -25,6 +25,8 @@ export const useEditUpstreamData = defineBasicLoader('/dashboard/upstreams/[id]' let upstreamModelsError: string | null = null; let copilotQuota: CopilotQuotaSnapshot | null = null; let copilotQuotaError: string | null = null; + let cursorQuota: CursorDashboardUsage | null = null; + let cursorQuotaError: string | null = null; // Every provider except Azure resolves its catalog through the SWR cache // backing GET /upstreams/:id/models. Azure's catalog is operator-edited @@ -33,15 +35,29 @@ export const useEditUpstreamData = defineBasicLoader('/dashboard/upstreams/[id]' const modelsPromise = callApi<{ data: UpstreamModelConfig[] }>( () => api.api.upstreams[':id'].models.$get({ param: { id: record.id } }), ); - const quotaPromise = record.kind === 'copilot' + const copilotQuotaPromise = record.kind === 'copilot' ? callApi(() => api.api.upstreams[':id'].copilot.quota.$get({ param: { id: record.id } })) : null; - const [modelsRes, quotaRes] = await Promise.all([modelsPromise, quotaPromise ?? Promise.resolve(null)]); + // Prefetch the Cursor dashboard usage for cursor upstreams; the panel's + // CursorAccountCard renders it on first paint (no spinner flicker). + // Skip if the account isn't active — the panel guards the same check. + const cursorQuotaPromise = record.kind === 'cursor' && record.state?.accounts[0]?.state === 'active' + ? callApi(() => api.api.upstreams[':id'].cursor.quota.$get({ param: { id: record.id } })) + : null; + const [modelsRes, copilotRes, cursorRes] = await Promise.all([ + modelsPromise, + copilotQuotaPromise ?? Promise.resolve(null), + cursorQuotaPromise ?? Promise.resolve(null), + ]); if (modelsRes.error) upstreamModelsError = modelsRes.error.message; else upstreamModels = modelsRes.data.data; - if (quotaRes) { - if (quotaRes.error) copilotQuotaError = quotaRes.error.message; - else copilotQuota = quotaRes.data; + if (copilotRes) { + if (copilotRes.error) copilotQuotaError = copilotRes.error.message; + else copilotQuota = copilotRes.data; + } + if (cursorRes) { + if (cursorRes.error) cursorQuotaError = cursorRes.error.message; + else cursorQuota = cursorRes.data; } } @@ -53,6 +69,8 @@ export const useEditUpstreamData = defineBasicLoader('/dashboard/upstreams/[id]' upstreamModelsError, copilotQuota, copilotQuotaError, + cursorQuota, + cursorQuotaError, }; }); @@ -83,6 +101,8 @@ if (data.data.value.record === null) { :initial-upstream-models-error="data.data.value.upstreamModelsError" :initial-copilot-quota="data.data.value.copilotQuota" :initial-copilot-quota-error="data.data.value.copilotQuotaError" + :initial-cursor-quota="data.data.value.cursorQuota" + :initial-cursor-quota-error="data.data.value.cursorQuotaError" @saved="store.load" /> diff --git a/eslint.config.ts b/eslint.config.ts index 1217b601e..ca01a5008 100644 --- a/eslint.config.ts +++ b/eslint.config.ts @@ -20,6 +20,7 @@ const projectList = [ './packages/provider-azure/tsconfig.json', './packages/provider-claude-code/tsconfig.json', './packages/provider-codex/tsconfig.json', + './packages/provider-cursor/tsconfig.json', './packages/provider-copilot/tsconfig.json', './packages/provider-custom/tsconfig.json', './packages/provider-ollama/tsconfig.json', diff --git a/packages/gateway/migrations/0048_cursor_provider.sql b/packages/gateway/migrations/0048_cursor_provider.sql new file mode 100644 index 000000000..4b9546793 --- /dev/null +++ b/packages/gateway/migrations/0048_cursor_provider.sql @@ -0,0 +1,37 @@ +-- SQLite CHECK constraints on `upstreams.provider` are immutable; the standard +-- pattern in this repo is to rebuild the table (see 0027_codex_provider.sql, +-- 0034_ollama_provider.sql, 0038_claude_code_provider.sql). The new value +-- 'cursor' joins the existing six kinds; no row data changes. + +CREATE TABLE upstreams_new ( + id TEXT PRIMARY KEY, + provider TEXT NOT NULL CHECK (provider IN ('copilot', 'custom', 'azure', 'codex', 'ollama', 'claude-code', 'cursor')), + name TEXT NOT NULL, + enabled INTEGER NOT NULL DEFAULT 1 CHECK (enabled IN (0, 1)), + sort_order INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + config_json TEXT NOT NULL, + state_json TEXT NULL, + flag_overrides TEXT NOT NULL DEFAULT '[]', + disabled_public_model_ids TEXT NOT NULL DEFAULT '[]', + proxy_fallback_list_json TEXT NOT NULL DEFAULT '[]', + model_prefix_json TEXT NULL +); + +INSERT INTO upstreams_new + (id, provider, name, enabled, sort_order, created_at, updated_at, + config_json, state_json, flag_overrides, disabled_public_model_ids, + proxy_fallback_list_json, model_prefix_json) +SELECT + id, provider, name, enabled, sort_order, created_at, updated_at, + config_json, state_json, flag_overrides, disabled_public_model_ids, + proxy_fallback_list_json, model_prefix_json +FROM upstreams; + +DROP TABLE upstreams; +ALTER TABLE upstreams_new RENAME TO upstreams; + +CREATE INDEX idx_upstreams_sort ON upstreams (sort_order, created_at); +CREATE INDEX idx_upstreams_provider_enabled_sort + ON upstreams (provider, enabled, sort_order, created_at); \ No newline at end of file diff --git a/packages/gateway/migrations/0049_cursor_sessions.sql b/packages/gateway/migrations/0049_cursor_sessions.sql new file mode 100644 index 000000000..fb3d19be0 --- /dev/null +++ b/packages/gateway/migrations/0049_cursor_sessions.sql @@ -0,0 +1,30 @@ +-- D1/sqlite table backing cross-instance Cursor session reuse. +-- +-- A Cursor agent turn keeps a long-lived RunSSE read stream open (held by the +-- DurableHttpSession) plus a monotonic BidiAppend write `seqno`. When a +-- tool-result follow-up lands on a DIFFERENT isolate, the in-process transport +-- is gone — so the two scalars needed to resume (the upstream `request_id` and +-- the next `append_seqno`) live here in D1 instead, keyed by session. +-- +-- `leftover` carries any RunSSE bytes the prior turn's frame parser read past +-- the exec_mcp frame but did not consume (usually empty — Cursor pauses right +-- after exec_mcp). The blobStore is NOT stored: Cursor only ever set_blob's +-- (write-only sink), so a follow-up instance starts with an empty one. +-- +-- `locked_until` is a single-flight claim lock (unix ms): a follow-up CAS-claims +-- the row before resuming so two concurrent follow-ups can't both drive the +-- same stream and corrupt the seqno; the loser falls back to cold-resume. +-- +-- Rows are short-lived (a live conversation turn, not durable history) and are +-- swept by the maintenance cron against `refreshed_at`. +CREATE TABLE cursor_sessions ( + session_key TEXT PRIMARY KEY, + request_id TEXT NOT NULL, + append_seqno INTEGER NOT NULL, + leftover BLOB, + locked_until INTEGER, -- unix ms; NULL = unclaimed + created_at INTEGER NOT NULL, -- unix ms + refreshed_at INTEGER NOT NULL -- unix ms; cron sweeps by this +); + +CREATE INDEX idx_cursor_sessions_refreshed_at ON cursor_sessions(refreshed_at); diff --git a/packages/gateway/migrations/0050_cursor_drop_quotasnapshot.sql b/packages/gateway/migrations/0050_cursor_drop_quotasnapshot.sql new file mode 100644 index 000000000..87f397dba --- /dev/null +++ b/packages/gateway/migrations/0050_cursor_drop_quotasnapshot.sql @@ -0,0 +1,20 @@ +-- Simplify's 59a6e66d removed the always-null `quotaSnapshot` field from +-- CursorAccountCredential and dropped `quotaSnapshot: true` from the state +-- assertion's allowed-key allowlist. Any pre-existing cursor rows carry the +-- key with value null (initial import path in auth/import.ts wrote it +-- unconditionally). Post-cleanup, `assertCursorUpstreamState` rejects the +-- key and every cursor request fails with `unexpected key 'quotaSnapshot'`. +-- +-- Scrub the key from every cursor account's state_json. Idempotent — rows +-- without the key are left unchanged. SQLite's json_remove treats a missing +-- path as a no-op. + +UPDATE upstreams +SET state_json = json_replace( + state_json, + '$.accounts[0]', + json_remove(json_extract(state_json, '$.accounts[0]'), '$.quotaSnapshot') +) +WHERE provider = 'cursor' + AND state_json IS NOT NULL + AND json_type(state_json, '$.accounts[0].quotaSnapshot') IS NOT NULL; diff --git a/packages/gateway/package.json b/packages/gateway/package.json index 802097f0b..f3a40ce3c 100644 --- a/packages/gateway/package.json +++ b/packages/gateway/package.json @@ -46,6 +46,7 @@ "@floway-dev/provider-azure": "workspace:*", "@floway-dev/provider-claude-code": "workspace:*", "@floway-dev/provider-codex": "workspace:*", + "@floway-dev/provider-cursor": "workspace:*", "@floway-dev/provider-copilot": "workspace:*", "@floway-dev/provider-custom": "workspace:*", "@floway-dev/provider-ollama": "workspace:*", diff --git a/packages/gateway/src/control-plane/cursor-quota/routes.ts b/packages/gateway/src/control-plane/cursor-quota/routes.ts new file mode 100644 index 000000000..d2157320c --- /dev/null +++ b/packages/gateway/src/control-plane/cursor-quota/routes.ts @@ -0,0 +1,91 @@ +import type { Context } from 'hono'; + +import { createPerRequestFetcher } from '../../dial/per-request.ts'; +import { getRepo } from '../../repo/index.ts'; +import { getCurrentColo } from '../../runtime/runtime-info.ts'; +import { + assertCursorUpstreamState, + CursorDashboardSessionExpiredError, + CursorDashboardUpstreamError, + CursorSessionTerminatedError, + ensureCursorAccessToken, + fetchCursorDashboardUsage, + mintCursorAccessToken, + type CursorUpstreamState, +} from '@floway-dev/provider-cursor'; + +// GET /api/upstreams/:id/cursor/quota — on-demand fetch of the current +// billing-cycle usage from cursor.com/dashboard/spending's endpoint. Mirrors +// the copilot-quota shape (control-plane pull, no persistence). See +// packages/provider-cursor/src/quota.ts for the fetcher + error taxonomy. +export const cursorQuota = async (c: Context) => { + try { + const id = c.req.param('id')!; + const upstream = await getRepo().upstreams.getById(id); + if (upstream?.kind !== 'cursor') { + return c.json({ error: 'Cursor upstream not found' }, 404); + } + assertCursorUpstreamState(upstream.state); + const account = upstream.state.accounts[0]!; + if (account.state !== 'active') { + return c.json({ error: `Cursor upstream is ${account.state}; re-import to recover` }, 400); + } + + const fetcherForUpstream = await createPerRequestFetcher(getCurrentColo(c.req.raw)); + const fetcher = fetcherForUpstream(upstream.id); + + // Rotated-refresh-token persistence hook for ensureCursorAccessToken. + // Re-reads state before writing so we don't clobber a racing update. + const persistRefreshTokenRotation = async (newRefreshToken: string): Promise => { + const fresh = await getRepo().upstreams.getById(id); + if (fresh?.kind !== 'cursor') return; + assertCursorUpstreamState(fresh.state); + const nextState: CursorUpstreamState = { + ...fresh.state, + accounts: fresh.state.accounts.map(a => + a.userId === account.userId + ? { ...a, refresh_token: newRefreshToken, state_updated_at: new Date().toISOString() } + : a), + }; + await getRepo().upstreams.saveState(id, nextState, { expectedState: fresh.state }); + }; + + let entry; + try { + entry = await ensureCursorAccessToken(id, account.userId, refresh => + mintCursorAccessToken(refresh, fetcher, persistRefreshTokenRotation)); + } catch (err) { + // A dead refresh_token surfaces here; the account flip to refresh_failed + // is owned by the data-plane's persistTerminalState (fetch.ts). Map to + // 502 (not 401 — the frontend authFetch treats 401 as *our* session + // expiring and force-logs-out) with a machine-readable `kind` field so + // the panel can render a re-import hint. + if (err instanceof CursorSessionTerminatedError) { + return c.json( + { + error: `Cursor refresh failed: ${err.upstreamMessage}. Re-import the credential to recover.`, + kind: 'session_expired' as const, + }, + 502, + ); + } + throw err; + } + + try { + const usage = await fetchCursorDashboardUsage({ userId: account.userId, accessToken: entry.token, fetcher }); + return c.json(usage); + } catch (err) { + if (err instanceof CursorDashboardSessionExpiredError) { + return c.json({ error: err.message, kind: 'session_expired' as const }, 502); + } + if (err instanceof CursorDashboardUpstreamError) { + return c.json({ error: err.message }, 502); + } + throw err; + } + } catch (e: unknown) { + console.error('Failed to fetch Cursor dashboard usage:', e); + return c.json({ error: 'Failed to fetch Cursor dashboard usage' }, 502); + } +}; diff --git a/packages/gateway/src/control-plane/data-transfer/routes.ts b/packages/gateway/src/control-plane/data-transfer/routes.ts index 1a74d0b3a..6d51be84c 100644 --- a/packages/gateway/src/control-plane/data-transfer/routes.ts +++ b/packages/gateway/src/control-plane/data-transfer/routes.ts @@ -35,6 +35,7 @@ import type { ProxyFallbackEntry, UpstreamProviderKind, UpstreamRecord } from '@ import { assertAzureUpstreamRecord } from '@floway-dev/provider-azure'; import { assertClaudeCodeUpstreamRecord, assertClaudeCodeUpstreamState } from '@floway-dev/provider-claude-code'; import { assertCodexUpstreamRecord, assertCodexUpstreamState } from '@floway-dev/provider-codex'; +import { assertCursorUpstreamRecord, assertCursorUpstreamState } from '@floway-dev/provider-cursor'; import { assertCustomUpstreamRecord } from '@floway-dev/provider-custom'; import { parseProxyUri } from '@floway-dev/proxy'; @@ -93,6 +94,10 @@ const normalizeUpstreamConfig = (record: UpstreamRecord): unknown => { assertClaudeCodeUpstreamRecord(record); return record.config; } + if (record.kind === 'cursor') { + assertCursorUpstreamRecord(record); + return record.config; + } return copilotConfigField(record.config, importErrorBuilder); }; @@ -104,12 +109,13 @@ const normalizeUpstreamConfig = (record: UpstreamRecord): unknown => { // runtime uses so a corrupt or hand-edited import can't smuggle unknown // fields onto the column. const normalizeUpstreamState = (provider: UpstreamProviderKind, value: unknown): unknown => { - if (provider !== 'codex' && provider !== 'claude-code') return null; + if (provider !== 'codex' && provider !== 'claude-code' && provider !== 'cursor') return null; if (value === null || value === undefined) { throw new Error(`${provider} upstream is missing state — re-export with current code`); } if (provider === 'codex') assertCodexUpstreamState(value); - else assertClaudeCodeUpstreamState(value); + else if (provider === 'claude-code') assertClaudeCodeUpstreamState(value); + else assertCursorUpstreamState(value); return value; }; diff --git a/packages/gateway/src/control-plane/routes.ts b/packages/gateway/src/control-plane/routes.ts index 60fb9404c..1f6d122b6 100644 --- a/packages/gateway/src/control-plane/routes.ts +++ b/packages/gateway/src/control-plane/routes.ts @@ -3,17 +3,18 @@ import { Hono, type Next } from 'hono'; import { createKey, deleteKey, listKeys, rotateKey, updateKey } from './api-keys/routes.ts'; import { authLogin, authLogout, authMe } from './auth/routes.ts'; import { copilotQuota } from './copilot-quota/routes.ts'; +import { cursorQuota } from './cursor-quota/routes.ts'; import { exportData, importData } from './data-transfer/routes.ts'; import { dumpRoutes } from './dump.ts'; import { createAlias, deleteAlias, listAliases, updateAlias } from './model-aliases/routes.ts'; import { controlPlaneModels } from './models/routes.ts'; import { performanceOverview, performanceTelemetry } from './performance/routes.ts'; import { createProxy, deleteProxy, listAllBackoffs, listProxies, listProxyBackoffs, resetProxyBackoffs, testProxy, updateProxy } from './proxies/routes.ts'; -import { authLoginBody, changeOwnPasswordBody, claudeCodeAuthorizeUrlBody, claudeCodeImportBody, claudeCodeProbeQuotaBody, claudeCodeRefreshNowBody, claudeCodeReimportBody, claudeCodeSetupTokenImportBody, claudeCodeSetupTokenReimportBody, codexAuthorizeUrlBody, codexImportBody, codexRefreshNowBody, codexReimportBody, copilotAuthPollBody, createAliasBody, createKeyBody, createProxyBody, createUpstreamBody, createUserBody, exportQuery, fetchModelsBody, importBody, modelsQuery, performanceQuery, resetBackoffBody, searchConfigSchema, searchUsageQuery, testProxyBody, tokenUsageQuery, updateAliasBody, updateKeyBody, updateProxyBody, updateUpstreamBody, updateUserBody } from './schemas.ts'; +import { authLoginBody, changeOwnPasswordBody, claudeCodeAuthorizeUrlBody, claudeCodeImportBody, claudeCodeProbeQuotaBody, claudeCodeRefreshNowBody, claudeCodeReimportBody, claudeCodeSetupTokenImportBody, claudeCodeSetupTokenReimportBody, codexAuthorizeUrlBody, codexImportBody, codexRefreshNowBody, codexReimportBody, copilotAuthPollBody, createAliasBody, createKeyBody, createProxyBody, createUpstreamBody, createUserBody, cursorAuthorizeUrlBody, cursorPollBody, cursorReimportBody, cursorRefreshNowBody, exportQuery, fetchModelsBody, importBody, modelsQuery, performanceQuery, resetBackoffBody, searchConfigSchema, searchUsageQuery, testProxyBody, tokenUsageQuery, updateAliasBody, updateKeyBody, updateProxyBody, updateUpstreamBody, updateUserBody } from './schemas.ts'; import { getSearchConfigRoute, putSearchConfigRoute, testSearchConfigRoute } from './search-config/routes.ts'; import { searchUsage } from './search-usage/routes.ts'; import { tokenUsage } from './token-usage/routes.ts'; -import { claudeCodeAuthorizeUrl, claudeCodeImport, claudeCodeProbeQuota, claudeCodeRefreshNow, claudeCodeReimport, claudeCodeSetupTokenImport, claudeCodeSetupTokenReimport, codexAuthorizeUrl, codexImport, codexRefreshNow, codexReimport, copilotAuthPoll, copilotAuthStart, createUpstream, deleteUpstream, fetchModels, listOptionalFlags, listUpstreamModels, listUpstreamOptions, listUpstreams, updateUpstream } from './upstreams/routes.ts'; +import { claudeCodeAuthorizeUrl, claudeCodeImport, claudeCodeProbeQuota, claudeCodeRefreshNow, claudeCodeReimport, claudeCodeSetupTokenImport, claudeCodeSetupTokenReimport, codexAuthorizeUrl, codexImport, codexRefreshNow, codexReimport, copilotAuthPoll, copilotAuthStart, createUpstream, cursorAuthorizeUrl, cursorPoll, cursorReimport, cursorRefreshNow, deleteUpstream, fetchModels, listOptionalFlags, listUpstreamModels, listUpstreamOptions, listUpstreams, updateUpstream } from './upstreams/routes.ts'; import { changeOwnPassword, createUser, deleteUser, listUsers, updateUser } from './users/routes.ts'; import { type AuthedContext, type AuthVars, userFromContext } from '../middleware/auth.ts'; import { zValidator } from '../middleware/zod-validator.ts'; @@ -82,9 +83,14 @@ export const controlPlaneRoutes = new Hono<{ Variables: AuthVars }>() .post('/upstreams/:id/claude-code-probe-quota', zValidator('json', claudeCodeProbeQuotaBody), claudeCodeProbeQuota) .post('/upstreams/claude-code-setup-token-import', zValidator('json', claudeCodeSetupTokenImportBody), claudeCodeSetupTokenImport) .post('/upstreams/:id/claude-code-setup-token-reimport', zValidator('json', claudeCodeSetupTokenReimportBody), claudeCodeSetupTokenReimport) + .post('/upstreams/cursor-authorize-url', zValidator('json', cursorAuthorizeUrlBody), cursorAuthorizeUrl) + .post('/upstreams/cursor-poll', zValidator('json', cursorPollBody), cursorPoll) + .post('/upstreams/:id/cursor-reimport', zValidator('json', cursorReimportBody), cursorReimport) + .post('/upstreams/:id/cursor-refresh-now', zValidator('json', cursorRefreshNowBody), cursorRefreshNow) .post('/upstreams/fetch-models', zValidator('json', fetchModelsBody), fetchModels) .post('/upstreams', zValidator('json', createUpstreamBody), createUpstream) .get('/upstreams/:id/copilot/quota', copilotQuota) + .get('/upstreams/:id/cursor/quota', cursorQuota) .get('/upstreams/:id/models', listUpstreamModels) .patch('/upstreams/:id', zValidator('json', updateUpstreamBody), updateUpstream) .delete('/upstreams/:id', deleteUpstream) diff --git a/packages/gateway/src/control-plane/schemas.ts b/packages/gateway/src/control-plane/schemas.ts index a259fca65..89fb3a5cf 100644 --- a/packages/gateway/src/control-plane/schemas.ts +++ b/packages/gateway/src/control-plane/schemas.ts @@ -313,7 +313,7 @@ const upstreamBaseFields = { // `sort_order` are optional — the handler defaults them to `true` and // `nextSortOrder()` respectively when omitted. // -// `codex` and `claude-code` are listed here so the handler can return the +// `codex`, `claude-code`, and `cursor` are listed here so the handler can return the // canonical "use POST /api/upstreams/-import" 400 instead of the // cryptic zod "invalid discriminator value" message. The `config` slot is // `unknown()` because the real config is derived from the OAuth flow, not @@ -324,6 +324,7 @@ export const createUpstreamBody = z.discriminatedUnion('kind', [ z.object({ kind: z.literal('copilot'), ...upstreamBaseFields, config: copilotConfigSchema }), z.object({ kind: z.literal('codex'), ...upstreamBaseFields, config: z.unknown() }), z.object({ kind: z.literal('claude-code'), ...upstreamBaseFields, config: z.unknown() }), + z.object({ kind: z.literal('cursor'), ...upstreamBaseFields, config: z.unknown() }), z.object({ kind: z.literal('ollama'), ...upstreamBaseFields, config: ollamaConfigSchema }), ]); @@ -337,7 +338,7 @@ export const createUpstreamBody = z.discriminatedUnion('kind', [ // without this field the schema would silently strip it and the API would // look like it had accepted the change. export const updateUpstreamBody = z.object({ - kind: z.enum(['custom', 'azure', 'copilot', 'codex', 'claude-code', 'ollama']).optional(), + kind: z.enum(['custom', 'azure', 'copilot', 'codex', 'claude-code', 'cursor', 'ollama']).optional(), name: z.string().min(1).optional(), enabled: z.boolean().optional(), sort_order: z.number().int().optional(), @@ -453,6 +454,31 @@ export const codexRefreshNowBody = z.object({ proxy_fallback_list: proxyFallbackListSchema.optional(), }); +// --- cursor import / authorize-url / poll / refresh --- +// See control-plane/upstreams/routes.ts for the poll-based login protocol. +export const cursorAuthorizeUrlBody = z.object({ + proxy_fallback_list: proxyFallbackListSchema.optional(), +}); + +export const cursorPollBody = z.object({ + uuid: z.string().min(1), + verifier: z.string().min(1), + name: z.string().min(1).optional(), + sort_order: z.number().int().optional(), + proxy_fallback_list: proxyFallbackListSchema.optional(), +}); + +export const cursorReimportBody = z.object({ + uuid: z.string().min(1), + verifier: z.string().min(1), + name: z.string().min(1).optional(), + proxy_fallback_list: proxyFallbackListSchema.optional(), +}); + +export const cursorRefreshNowBody = z.object({ + proxy_fallback_list: proxyFallbackListSchema.optional(), +}); + // --- claude-code import / authorize-url / refresh --- // // Same shape rationale as the codex routes above: the generic create / update diff --git a/packages/gateway/src/control-plane/upstreams/routes.ts b/packages/gateway/src/control-plane/upstreams/routes.ts index 6822d8e7f..ba5af94b7 100644 --- a/packages/gateway/src/control-plane/upstreams/routes.ts +++ b/packages/gateway/src/control-plane/upstreams/routes.ts @@ -15,7 +15,7 @@ import { backgroundSchedulerFromContext } from '../../runtime/background.ts'; import { getCurrentColo } from '../../runtime/runtime-info.ts'; import { shortId } from '../../shared/short-id.ts'; import { fetchGitHubUser, pollGitHubDeviceFlow, startGitHubDeviceFlow } from '../auth/github-device-flow.ts'; -import type { claudeCodeAuthorizeUrlBody, claudeCodeImportBody, claudeCodeProbeQuotaBody, claudeCodeRefreshNowBody, claudeCodeReimportBody, claudeCodeSetupTokenImportBody, claudeCodeSetupTokenReimportBody, codexAuthorizeUrlBody, codexImportBody, codexRefreshNowBody, codexReimportBody, copilotAuthPollBody, createUpstreamBody, fetchModelsBody, updateUpstreamBody } from '../schemas.ts'; +import type { claudeCodeAuthorizeUrlBody, claudeCodeImportBody, claudeCodeProbeQuotaBody, claudeCodeRefreshNowBody, claudeCodeReimportBody, claudeCodeSetupTokenImportBody, claudeCodeSetupTokenReimportBody, codexAuthorizeUrlBody, codexImportBody, codexRefreshNowBody, codexReimportBody, copilotAuthPollBody, createUpstreamBody, cursorAuthorizeUrlBody, cursorPollBody, cursorReimportBody, cursorRefreshNowBody, fetchModelsBody, updateUpstreamBody } from '../schemas.ts'; import { copilotConfigField, type CopilotUpstreamConfig, isRecord } from '../shared/field-validators.ts'; import { directFetcher, @@ -62,6 +62,19 @@ import { refreshCodexAccessToken, } from '@floway-dev/provider-codex'; import { clearInProcessCopilotTokenCache, exchangeCopilotToken, readCopilotUpstreamState, type CopilotUpstreamState } from '@floway-dev/provider-copilot'; +import { + generateCursorAuthParams, + pollCursorAuth, + deriveCursorIdentity, + buildCursorImportConfig, + buildCursorImportState, + refreshCursorAccessToken, + CursorSessionTerminatedError, + assertCursorUpstreamRecord, + assertCursorUpstreamState, + type CursorUpstreamConfig, + type CursorUpstreamState, +} from '@floway-dev/provider-cursor'; import { assertCustomUpstreamRecord, fetchCustomModels } from '@floway-dev/provider-custom'; import { assertOllamaUpstreamRecord, createOllamaProvider } from '@floway-dev/provider-ollama'; @@ -128,6 +141,10 @@ const normalizeConfig = (record: UpstreamRecord): ValidationResult => { assertClaudeCodeUpstreamRecord(record); return { ok: true, value: record.config }; } + if (record.kind === 'cursor') { + assertCursorUpstreamRecord(record); + return { ok: true, value: record.config }; + } return { ok: true, value: copilotConfigField( @@ -247,6 +264,11 @@ export const createUpstream = async (c: CtxWithJson) if (body.kind === 'claude-code') { return c.json({ error: 'Use POST /api/upstreams/claude-code-import for claude-code provider' }, 400); } + // Same rationale for cursor: the row carries an OAuth refresh token derived + // from the poll-based login flow, not a config the operator can type in. + if (body.kind === 'cursor') { + return c.json({ error: 'Use POST /api/upstreams/cursor-authorize-url + /api/upstreams/cursor-poll for cursor provider' }, 400); + } const proxyFallbackList = normalizeProxyFallbackList(body.proxy_fallback_list ?? []); const fallbackCheck = await validateProxyFallbackList(proxyFallbackList); @@ -306,6 +328,16 @@ export const updateUpstream = async (c: CtxWithJson) => { + const params = await generateCursorAuthParams(); + return c.json({ authorize_url: params.loginUrl, uuid: params.uuid, verifier: params.verifier }); +}; + +const ingestCursorPoll = async ( + uuid: string, + verifier: string, + fetcher: Fetcher, +): Promise<{ ok: true; config: CursorUpstreamConfig; state: CursorUpstreamState } | { ok: false; error: string }> => { + try { + const tokens = await pollCursorAuth(uuid, verifier, fetcher); + const config = buildCursorImportConfig(deriveCursorIdentity(tokens.accessToken)); + const state = buildCursorImportState(tokens); + return { ok: true, config, state }; + } catch (err) { + return { ok: false, error: errorMessage(err) }; + } +}; + +export const cursorPoll = async (c: CtxWithJson) => { + const body = c.req.valid('json'); + let fetcher: Fetcher; + try { + fetcher = await resolveControlPlaneFetcher({ override: body.proxy_fallback_list, currentColo: getCurrentColo(c.req.raw) }); + } catch (err) { + return c.json({ error: errorMessage(err) }, 400); + } + const ingestion = await ingestCursorPoll(body.uuid, body.verifier, fetcher); + if (!ingestion.ok) return c.json({ error: ingestion.error }, 400); + + const existing = await getRepo().upstreams.list(); + const now = new Date().toISOString(); + const defaultName = `Cursor (${ingestion.config.accounts[0].email})`; + const upstream: UpstreamRecord = { + id: newId(), + kind: 'cursor', + name: body.name ?? defaultName, + enabled: true, + sortOrder: body.sort_order ?? nextSortOrder(existing), + createdAt: now, + updatedAt: now, + flagOverrides: {}, + disabledPublicModelIds: [], + proxyFallbackList: body.proxy_fallback_list !== undefined ? normalizeProxyFallbackList(body.proxy_fallback_list) : [], + modelPrefix: null, + config: ingestion.config, + state: ingestion.state, + }; + await getRepo().upstreams.save(upstream); + await warmModelsCache(upstream, c); + return c.json(await serializeForResponse(upstream), 201); +}; + +export const cursorReimport = async (c: CtxWithJson) => { + const id = c.req.param('id'); + const existing = await getRepo().upstreams.getById(id); + if (existing?.kind !== 'cursor') { + return c.json({ error: 'Cursor upstream not found' }, 404); + } + const body = c.req.valid('json'); + let fetcher: Fetcher; + try { + fetcher = await resolveControlPlaneFetcher({ override: body.proxy_fallback_list, upstreamId: id, currentColo: getCurrentColo(c.req.raw) }); + } catch (err) { + return c.json({ error: errorMessage(err) }, 400); + } + const ingestion = await ingestCursorPoll(body.uuid, body.verifier, fetcher); + if (!ingestion.ok) return c.json({ error: ingestion.error }, 400); + + const next: UpstreamRecord = { + ...existing, + updatedAt: new Date().toISOString(), + name: body.name ?? existing.name, + proxyFallbackList: body.proxy_fallback_list !== undefined ? normalizeProxyFallbackList(body.proxy_fallback_list) : existing.proxyFallbackList, + config: ingestion.config, + state: ingestion.state, + }; + await getRepo().upstreams.save(next); + await warmModelsCache(next, c); + return c.json(await serializeForResponse(next)); +}; + +export const cursorRefreshNow = async (c: CtxWithJson) => { + const id = c.req.param('id'); + const existing = await getRepo().upstreams.getById(id); + if (existing?.kind !== 'cursor') { + return c.json({ error: 'Cursor upstream not found' }, 404); + } + assertCursorUpstreamState(existing.state); + const state = existing.state; + const account = state.accounts[0]; + if (account.state !== 'active') { + return c.json({ error: `Cursor upstream is ${account.state}; re-import to recover` }, 400); + } + + const body = c.req.valid('json'); + let fetcher: Fetcher; + try { + fetcher = await resolveControlPlaneFetcher({ override: body.proxy_fallback_list, upstreamId: id, currentColo: getCurrentColo(c.req.raw) }); + } catch (err) { + return c.json({ error: errorMessage(err) }, 400); + } + + try { + const tokens = await refreshCursorAccessToken(account.refresh_token, fetcher); + const now = new Date(); + const nextAccount = { + ...account, + refresh_token: tokens.refresh_token, + accessToken: { + token: tokens.access_token, + expiresAt: tokens.expires_at, + refreshedAt: now.toISOString(), + }, + }; + const nextState: CursorUpstreamState = { accounts: [nextAccount] }; + const result = await getRepo().upstreams.saveState(id, nextState, { expectedState: state }); + if (!result.updated) { + return c.json({ error: 'Concurrent state mutation; refresh aborted' }, 409); + } + return await respondWithFreshRow(id, c); + } catch (err) { + if (err instanceof CursorSessionTerminatedError) { + const failedAccount = { + ...account, + state: 'refresh_failed' as const, + state_message: err.upstreamMessage, + state_updated_at: new Date().toISOString(), + accessToken: null, + }; + const failedState: CursorUpstreamState = { accounts: [failedAccount] }; + await getRepo().upstreams.saveState(id, failedState, { expectedState: state }); + return c.json({ error: `Cursor refresh failed: ${err.upstreamMessage}. Re-import the credential to recover.` }, 400); + } + return c.json({ error: errorMessage(err) }, 502); + } +}; + // Stateless authorize-URL builder shared by both OAuth and Setup-Token // import flows. PKCE state is owned by the dashboard (verifier + state are // generated client-side and stashed in sessionStorage); the server only diff --git a/packages/gateway/src/control-plane/upstreams/routes_test.ts b/packages/gateway/src/control-plane/upstreams/routes_test.ts index 15be0deaa..2c213ac34 100644 --- a/packages/gateway/src/control-plane/upstreams/routes_test.ts +++ b/packages/gateway/src/control-plane/upstreams/routes_test.ts @@ -1043,6 +1043,61 @@ test('PATCH /api/upstreams rejects config edits on a claude-code row', async () assertEquals(body.error.includes('claude-code-reimport'), true); }); +test('PATCH /api/upstreams accepts a privacyMode edit on a cursor row but rejects credential edits', async () => { + const { repo, adminSession } = await setupAppTest(); + await repo.upstreams.deleteAll(); + await repo.upstreams.save({ + id: 'up_cursor_privacy', + kind: 'cursor', + name: 'Cursor', + enabled: true, + sortOrder: 0, + createdAt: '2026-05-22T00:00:00.000Z', + updatedAt: '2026-05-22T00:00:00.000Z', + flagOverrides: {}, + disabledPublicModelIds: [], + proxyFallbackList: [], + modelPrefix: null, + config: { accounts: [{ email: 'a@b.com', userId: 'u1' }] }, + state: { + accounts: [{ + userId: 'u1', + refresh_token: 'rt', + state: 'active', + state_updated_at: '2026-01-01T00:00:00.000Z', + // Far-future token so warmModelsCache never mints (network-free); its + // catalog fetch is mocked below and swallowed on failure. + accessToken: { token: 'at.cursor.test', expiresAt: 4102444800000, refreshedAt: '2026-01-01T00:00:00.000Z' }, + }], + }, + }); + + // Credential (accounts) edits still belong to cursor-reimport → 400. + const rejected = await requestApp('/api/upstreams/up_cursor_privacy', { + method: 'PATCH', + headers: { 'content-type': 'application/json', 'x-floway-session': adminSession }, + body: JSON.stringify({ config: { accounts: [] } }), + }); + assertEquals(rejected.status, 400); + assertEquals(((await rejected.json()) as { error: string }).error.includes('cursor-reimport'), true); + + // A privacyMode-only edit is accepted and merged over the existing config, so + // the account credentials survive. + const accepted = await withMockedFetch( + () => new Response('', { status: 500 }), + () => requestApp('/api/upstreams/up_cursor_privacy', { + method: 'PATCH', + headers: { 'content-type': 'application/json', 'x-floway-session': adminSession }, + body: JSON.stringify({ config: { privacyMode: false } }), + }), + ); + assertEquals(accepted.status, 200); + const stored = await repo.upstreams.getById('up_cursor_privacy'); + const cfg = stored?.config as { privacyMode?: boolean; accounts?: unknown[] }; + assertEquals(cfg.privacyMode, false); + assertEquals(cfg.accounts?.length, 1); +}); + test('PATCH /api/upstreams accepts metadata edits on a claude-code row', async () => { const { repo, adminSession } = await setupAppTest(); await repo.upstreams.deleteAll(); diff --git a/packages/gateway/src/control-plane/upstreams/serialize.ts b/packages/gateway/src/control-plane/upstreams/serialize.ts index 1db2d9438..3d5ef0368 100644 --- a/packages/gateway/src/control-plane/upstreams/serialize.ts +++ b/packages/gateway/src/control-plane/upstreams/serialize.ts @@ -103,6 +103,17 @@ const redactedConfig = (upstream: UpstreamRecord): unknown => { ...(a.rateLimitTier !== undefined ? { rateLimitTier: clone(a.rateLimitTier) } : {}), })), }; + case 'cursor': + // refresh_token lives in state and is redacted by redactedState. + return { + accounts: assertAccountsArray(upstream, config.accounts).map(a => ({ + ...(a.email !== undefined ? { email: clone(a.email) } : {}), + ...(a.userId !== undefined ? { userId: clone(a.userId) } : {}), + })), + ...(config.maxMode !== undefined ? { maxMode: clone(config.maxMode) } : {}), + ...(config.tabCompletion !== undefined ? { tabCompletion: clone(config.tabCompletion) } : {}), + ...(config.privacyMode !== undefined ? { privacyMode: clone(config.privacyMode) } : {}), + }; case 'ollama': return { ...(config.baseUrl !== undefined ? { baseUrl: clone(config.baseUrl) } : {}), @@ -159,6 +170,25 @@ const redactedState = (upstream: UpstreamRecord): unknown => { }; }), }; + case 'cursor': + return { + accounts: assertAccountsArray(upstream, state.accounts).map(a => { + // accessToken.token is dropped; expiresAt + refreshedAt are surfaced to the dashboard. + const accessToken = a.accessToken === null + ? null + : isRecord(a.accessToken) + ? { expiresAt: clone(a.accessToken.expiresAt), refreshedAt: clone(a.accessToken.refreshedAt) } + : (() => { throw new Error(`Upstream ${upstream.id} (${upstream.kind}) has malformed accessToken: expected object or null`); })(); + return { + ...(a.userId !== undefined ? { userId: clone(a.userId) } : {}), + ...(a.state !== undefined ? { state: clone(a.state) } : {}), + ...(a.state_message !== undefined ? { state_message: clone(a.state_message) } : {}), + state_updated_at: clone(a.state_updated_at), + refresh_token_set: hasSecret(a.refresh_token), + accessToken, + }; + }), + }; case 'copilot': { // Expose only the per-tier baseUrl the dashboard renders an account-type // badge from. Bearer token + expiry stay server-side: short-lived auth diff --git a/packages/gateway/src/data-plane/chat/responses/interceptors/server-tools/image-generation.ts b/packages/gateway/src/data-plane/chat/responses/interceptors/server-tools/image-generation.ts index 5ab9e3e3f..a14bc4eaa 100644 --- a/packages/gateway/src/data-plane/chat/responses/interceptors/server-tools/image-generation.ts +++ b/packages/gateway/src/data-plane/chat/responses/interceptors/server-tools/image-generation.ts @@ -645,6 +645,7 @@ const issueImageCall = async ( recordUpstreamLatency: recorder.record, waitUntil: state.backgroundScheduler, headers: new Headers(), + apiKeyId: state.apiKeyId, }; const { response, modelKey } = await (isEdit ? provider.instance.callImagesEdits(model, buildEditsForm(prompt, state.config, sources, stream), state.downstreamAbortSignal, opts) diff --git a/packages/gateway/src/data-plane/chat/shared/attempt-helpers.ts b/packages/gateway/src/data-plane/chat/shared/attempt-helpers.ts index f04299639..39af6cadd 100644 --- a/packages/gateway/src/data-plane/chat/shared/attempt-helpers.ts +++ b/packages/gateway/src/data-plane/chat/shared/attempt-helpers.ts @@ -74,6 +74,7 @@ export const buildUpstreamCallOptions = ( recordUpstreamLatency, waitUntil: ctx.backgroundScheduler, headers, + apiKeyId: ctx.apiKeyId, }); // Lifts a provider's streaming-call result into the attempt's ExecuteResult diff --git a/packages/gateway/src/data-plane/chat/shared/respond.ts b/packages/gateway/src/data-plane/chat/shared/respond.ts index de0ce67f9..44b1ff78b 100644 --- a/packages/gateway/src/data-plane/chat/shared/respond.ts +++ b/packages/gateway/src/data-plane/chat/shared/respond.ts @@ -35,10 +35,14 @@ export class SourceStreamState { completed = false; usage: TokenUsage | null = null; - // Only a frame carrying real (non-zero) usage overwrites the running figure, - // so an empty trailing frame can't wipe a good count. + // Priority order: a frame carrying real (non-zero) usage always wins, so a + // trailing empty frame cannot wipe a good count. If ONLY zero-usage frames + // appear — a provider that could not recover any per-request token count — + // keep the first one, so `usage` becomes a non-null empty object that + // recordUsage logs as a bare request row. rememberUsage(usage: TokenUsage | null): void { if (usage && hasTokenUsage(usage)) this.usage = usage; + else if (usage && this.usage === null) this.usage = usage; } // Whether the streamed response should be recorded as failed: an upstream or @@ -58,7 +62,14 @@ export const eventResultMetadata = async (result: Extract => { - if (usage && hasTokenUsage(usage)) await recordTokenUsage(ctx.apiKeyId, modelIdentity, usage); + // Record whenever the provider produced a usage object at all — even one + // with no token dimensions. recordTokenUsage always bumps the request + // counter and only writes token dimensions that are > 0, so an empty usage + // lands a bare request row (usage_requests +1, no usage dimension rows). + // Providers that can't recover per-request tokens use this to keep the + // request-count telemetry correct; `null` (provider produced no usage + // object) is still skipped. + if (usage) await recordTokenUsage(ctx.apiKeyId, modelIdentity, usage); }; export const recordPerformance = (ctx: GatewayCtx, context: EventResultMetadata['performance'], failed: boolean): void => { diff --git a/packages/gateway/src/data-plane/chat/shared/respond_test.ts b/packages/gateway/src/data-plane/chat/shared/respond_test.ts index 328da2ee5..d514027ca 100644 --- a/packages/gateway/src/data-plane/chat/shared/respond_test.ts +++ b/packages/gateway/src/data-plane/chat/shared/respond_test.ts @@ -159,8 +159,13 @@ test('recordUsage is a no-op when usage is null', async () => { assertEquals(await harness.repo.usage.listAll(), []); }); -test('recordUsage is a no-op when usage carries no billable dimensions', async () => { +test('recordUsage records a bare request when usage carries no billable dimensions', async () => { + // A non-null-but-empty usage (cursor: no per-request tokens, but the request + // still happened) records a request row with zero token dimensions. await recordUsage(harness.ctx(), testTelemetryModelIdentity, {}); - assertEquals(await harness.repo.usage.listAll(), []); + const rows = await harness.repo.usage.listAll(); + assertEquals(rows.length, 1); + assertEquals(rows[0].tokens, {}); + assertEquals(rows[0].requests, 1); }); diff --git a/packages/gateway/src/data-plane/providers/registry.ts b/packages/gateway/src/data-plane/providers/registry.ts index 2fa8aa0f2..c1b728dd9 100644 --- a/packages/gateway/src/data-plane/providers/registry.ts +++ b/packages/gateway/src/data-plane/providers/registry.ts @@ -12,6 +12,7 @@ import { createAzureProvider } from '@floway-dev/provider-azure'; import { createClaudeCodeProvider } from '@floway-dev/provider-claude-code'; import { createCodexProvider } from '@floway-dev/provider-codex'; import { createCopilotProvider } from '@floway-dev/provider-copilot'; +import { createCursorProvider } from '@floway-dev/provider-cursor'; import { createCustomProvider } from '@floway-dev/provider-custom'; import { createOllamaProvider } from '@floway-dev/provider-ollama'; @@ -40,6 +41,7 @@ const providerFactories: Record = { azure: createAzureProvider, codex: createCodexProvider, 'claude-code': createClaudeCodeProvider, + cursor: createCursorProvider, ollama: createOllamaProvider, }; diff --git a/packages/gateway/src/data-plane/shared/passthrough-attempt.ts b/packages/gateway/src/data-plane/shared/passthrough-attempt.ts index 0d7835791..c8f69216d 100644 --- a/packages/gateway/src/data-plane/shared/passthrough-attempt.ts +++ b/packages/gateway/src/data-plane/shared/passthrough-attempt.ts @@ -56,6 +56,7 @@ export const passthroughAttempt = async (args: PassthroughAttemptArgs): Promise< recordUpstreamLatency: recorder.record, waitUntil: ctx.backgroundScheduler, headers: inboundHeadersForUpstream(c), + apiKeyId: ctx.apiKeyId, }); const upstreamDurationMs = requireRecordedDurationMs(recorder, 'passthrough upstream call'); // Telemetry keys on the upstream's bare catalog id (`model.id`); the diff --git a/packages/gateway/src/repo/cursor-sessions_test.ts b/packages/gateway/src/repo/cursor-sessions_test.ts new file mode 100644 index 000000000..59ffe839e --- /dev/null +++ b/packages/gateway/src/repo/cursor-sessions_test.ts @@ -0,0 +1,95 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { InMemoryRepo } from './memory.ts'; +import { SqlRepo } from './sql.ts'; +import { createSqliteTestDb } from './test-sqlite.ts'; +import type { CursorSessionRow, Repo } from './types.ts'; + +// cursor_sessions is the durable half of a cross-instance Cursor turn: the +// CAS claim lock (single-flight) and the BLOB leftover round-trip differ +// subtly between the SQL impl (RETURNING + sqlite BLOB binding) and the JS +// mirror, so run both backends. +const REPO_BACKENDS: Array Promise]> = [ + ['memory', async () => new InMemoryRepo()], + ['sql', async () => new SqlRepo(await createSqliteTestDb())], +]; + +const row = (overrides: Partial = {}): CursorSessionRow => ({ + sessionKey: 'cursor:up:key:auto:abc', + requestId: 'req-1', + appendSeqno: 3, + leftover: null, + ...overrides, +}); + +for (const [backend, makeRepo] of REPO_BACKENDS) { + describe(`[${backend}] cursor_sessions repo`, () => { + beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(new Date('2026-07-01T00:00:00Z')); + }); + afterEach(() => vi.useRealTimers()); + + it('claim on a missing session returns null', async () => { + const repo = await makeRepo(); + expect(await repo.cursorSessions.claim('missing', 1000)).toBeNull(); + }); + + it('put then claim returns the scalars and takes the lock', async () => { + const repo = await makeRepo(); + await repo.cursorSessions.put(row({ appendSeqno: 7 })); + const claimed = await repo.cursorSessions.claim('cursor:up:key:auto:abc', 1000); + expect(claimed).toEqual({ sessionKey: 'cursor:up:key:auto:abc', requestId: 'req-1', appendSeqno: 7, leftover: null }); + // Second concurrent claim while locked → null (single-flight). + expect(await repo.cursorSessions.claim('cursor:up:key:auto:abc', 1000)).toBeNull(); + }); + + it('claim succeeds again once the lock TTL expires', async () => { + const repo = await makeRepo(); + await repo.cursorSessions.put(row()); + await repo.cursorSessions.claim('cursor:up:key:auto:abc', 1000); + vi.advanceTimersByTime(1001); + expect(await repo.cursorSessions.claim('cursor:up:key:auto:abc', 1000)).not.toBeNull(); + }); + + it('put clears the lock and updates the seqno', async () => { + const repo = await makeRepo(); + await repo.cursorSessions.put(row({ appendSeqno: 3 })); + await repo.cursorSessions.claim('cursor:up:key:auto:abc', 60_000); // lock it + // A suspend persists the advanced seqno and clears the lock. + await repo.cursorSessions.put(row({ appendSeqno: 9 })); + const reclaimed = await repo.cursorSessions.claim('cursor:up:key:auto:abc', 1000); + expect(reclaimed?.appendSeqno).toBe(9); + }); + + it('round-trips a non-empty leftover BLOB and a null one', async () => { + const repo = await makeRepo(); + const bytes = new Uint8Array([0, 1, 2, 0xff, 0xfe, 0x80]); + await repo.cursorSessions.put(row({ leftover: bytes })); + const claimed = await repo.cursorSessions.claim('cursor:up:key:auto:abc', 1000); + expect(claimed?.leftover ? Array.from(claimed.leftover) : null).toEqual(Array.from(bytes)); + + await repo.cursorSessions.put(row({ leftover: null })); + vi.advanceTimersByTime(2000); + const claimed2 = await repo.cursorSessions.claim('cursor:up:key:auto:abc', 1000); + expect(claimed2?.leftover).toBeNull(); + }); + + it('delete removes the row', async () => { + const repo = await makeRepo(); + await repo.cursorSessions.put(row()); + await repo.cursorSessions.delete('cursor:up:key:auto:abc'); + expect(await repo.cursorSessions.claim('cursor:up:key:auto:abc', 1000)).toBeNull(); + }); + + it('deleteOlderThan sweeps rows refreshed before the cutoff', async () => { + const repo = await makeRepo(); + await repo.cursorSessions.put(row({ sessionKey: 'old' })); + vi.advanceTimersByTime(10_000); + await repo.cursorSessions.put(row({ sessionKey: 'fresh' })); + await repo.cursorSessions.deleteOlderThan(Date.now() - 5_000); + expect(await repo.cursorSessions.claim('old', 1000)).toBeNull(); + expect(await repo.cursorSessions.claim('fresh', 1000)).not.toBeNull(); + }); + }); +} diff --git a/packages/gateway/src/repo/index.ts b/packages/gateway/src/repo/index.ts index 5c4f51720..c1a2950af 100644 --- a/packages/gateway/src/repo/index.ts +++ b/packages/gateway/src/repo/index.ts @@ -1,13 +1,42 @@ +import { DIRECT_PROXY_ID } from './proxy-fallback-list.ts'; import type { Repo } from './types.ts'; import { initProviderRepo } from '@floway-dev/provider'; +import { parseProxyUri } from '@floway-dev/proxy'; let _repo: Repo | null = null; +// Resolve an upstream's proxy fallback list into ordered ProxyConfig objects +// (direct entries + malformed/missing rows dropped), for providers that dial +// streaming upstreams through the proxy directly rather than through the +// gateway's buffered proxy Fetcher (which rejects streaming bodies). Returned +// opaque (unknown[]) across the slim provider-repo boundary. +const resolveDirectDialProxies = async (repo: Repo, upstreamId: string): Promise => { + const upstream = await repo.upstreams.getById(upstreamId); + if (!upstream) return []; + const proxyIds = upstream.proxyFallbackList.filter(e => e.id !== DIRECT_PROXY_ID).map(e => e.id); + if (proxyIds.length === 0) return []; + const byId = new Map((await repo.proxies.list()).map(p => [p.id, p] as const)); + const out: unknown[] = []; + for (const id of proxyIds) { + const row = byId.get(id); + if (!row) continue; + try { out.push(parseProxyUri(row.url)); } catch { /* skip malformed row */ } + } + return out; +}; + export function initRepo(repo: Repo): void { _repo = repo; - // Hand provider-package helpers (models-store, etc.) a lazy accessor for the - // same singleton so they read the cache through the live repo. - initProviderRepo(() => getRepo()); + // Hand provider-package helpers (models-store, cursor session reuse, etc.) a + // lazy accessor for the same singleton so they read through the live repo. + initProviderRepo(() => { + const live = getRepo(); + return { + upstreams: live.upstreams, + cursorSessions: live.cursorSessions, + proxies: { resolveForUpstream: (upstreamId: string) => resolveDirectDialProxies(live, upstreamId) }, + }; + }); } export function getRepo(): Repo { diff --git a/packages/gateway/src/repo/memory.ts b/packages/gateway/src/repo/memory.ts index 8b05aee4f..43dabb679 100644 --- a/packages/gateway/src/repo/memory.ts +++ b/packages/gateway/src/repo/memory.ts @@ -13,6 +13,8 @@ import type { ApiKeyRepo, BackoffRow, CachedModelsRow, + CursorSessionRow, + CursorSessionsRepo, ModelAliasesRepo, ModelAliasRecord, ModelsCacheRepo, @@ -884,6 +886,31 @@ class MemoryProxyBackoffRepo implements ProxyBackoffRepo { const cloneBackoffRow = (row: BackoffRow): BackoffRow => ({ ...row }); +class MemoryCursorSessionsRepo implements CursorSessionsRepo { + private rows = new Map(); + + async claim(sessionKey: string, ttlMs: number): Promise { + const now = Date.now(); + const row = this.rows.get(sessionKey); + if (!row) return null; + if (row.lockedUntil !== null && row.lockedUntil >= now) return null; + row.lockedUntil = now + ttlMs; + return { sessionKey, requestId: row.requestId, appendSeqno: row.appendSeqno, leftover: row.leftover }; + } + + async put(row: CursorSessionRow): Promise { + this.rows.set(row.sessionKey, { ...row, lockedUntil: null, refreshedAt: Date.now() }); + } + + async delete(sessionKey: string): Promise { + this.rows.delete(sessionKey); + } + + async deleteOlderThan(cutoffMs: number): Promise { + for (const [k, v] of this.rows) if (v.refreshedAt < cutoffMs) this.rows.delete(k); + } +} + const cloneModelAliasRecord = (record: ModelAliasRecord): ModelAliasRecord => ({ ...record, targets: structuredClone(record.targets), @@ -947,6 +974,7 @@ export class InMemoryRepo implements Repo { modelAliases: ModelAliasesRepo; responsesItems: ResponsesItemsRepo; responsesSnapshots: ResponsesSnapshotsRepo; + cursorSessions: CursorSessionsRepo; constructor() { this.users = new MemoryUsersRepo(); @@ -963,5 +991,6 @@ export class InMemoryRepo implements Repo { this.modelAliases = new MemoryModelAliasesRepo(); this.responsesItems = new MemoryResponsesItemsRepo(); this.responsesSnapshots = new MemoryResponsesSnapshotsRepo(); + this.cursorSessions = new MemoryCursorSessionsRepo(); } } diff --git a/packages/gateway/src/repo/sql.ts b/packages/gateway/src/repo/sql.ts index 8e922091a..d99ba9b24 100644 --- a/packages/gateway/src/repo/sql.ts +++ b/packages/gateway/src/repo/sql.ts @@ -7,6 +7,8 @@ import type { ApiKeyRepo, BackoffRow, CachedModelsRow, + CursorSessionRow, + CursorSessionsRepo, ModelAliasesRepo, ModelAliasRecord, ModelsCacheRepo, @@ -1256,7 +1258,7 @@ const toUpstreamRecord = (row: UpstreamRow): UpstreamRecord => { }; const assertUpstreamProviderKind = (provider: string): UpstreamProviderKind => { - if (provider === 'copilot' || provider === 'custom' || provider === 'azure' || provider === 'codex' || provider === 'claude-code' || provider === 'ollama') return provider; + if (provider === 'copilot' || provider === 'custom' || provider === 'azure' || provider === 'codex' || provider === 'claude-code' || provider === 'ollama' || provider === 'cursor') return provider; throw new TypeError(`Invalid upstream provider kind: ${provider}`); }; @@ -1587,6 +1589,66 @@ const toBackoffRow = (row: BackoffRowDb): BackoffRow => ({ lastErrorAt: row.last_error_at, }); +interface CursorSessionRowDb { + request_id: string; + append_seqno: number; + leftover: Uint8Array | null; +} + +class SqlCursorSessionsRepo implements CursorSessionsRepo { + constructor(private db: SqlDatabase) {} + + async claim(sessionKey: string, ttlMs: number): Promise { + const now = Date.now(); + // Atomic single-flight: take the lock only if the row exists and is + // unclaimed (or its claim expired), returning the scalars in the same + // statement. RETURNING is supported by both D1 and node:sqlite. + const row = await this.db + .prepare( + `UPDATE cursor_sessions + SET locked_until = ? + WHERE session_key = ? AND (locked_until IS NULL OR locked_until < ?) + RETURNING request_id, append_seqno, leftover`, + ) + .bind(now + ttlMs, sessionKey, now) + .first(); + if (!row) return null; + return { + sessionKey, + requestId: row.request_id, + appendSeqno: row.append_seqno, + leftover: row.leftover ? new Uint8Array(row.leftover) : null, + }; + } + + async put(row: CursorSessionRow): Promise { + const now = Date.now(); + // Upsert the scalars; clear the claim lock and refresh the sweep clock. + await this.db + .prepare( + `INSERT INTO cursor_sessions + (session_key, request_id, append_seqno, leftover, locked_until, created_at, refreshed_at) + VALUES (?, ?, ?, ?, NULL, ?, ?) + ON CONFLICT (session_key) DO UPDATE SET + request_id = excluded.request_id, + append_seqno = excluded.append_seqno, + leftover = excluded.leftover, + locked_until = NULL, + refreshed_at = excluded.refreshed_at`, + ) + .bind(row.sessionKey, row.requestId, row.appendSeqno, row.leftover ?? null, now, now) + .run(); + } + + async delete(sessionKey: string): Promise { + await this.db.prepare('DELETE FROM cursor_sessions WHERE session_key = ?').bind(sessionKey).run(); + } + + async deleteOlderThan(cutoffMs: number): Promise { + await this.db.prepare('DELETE FROM cursor_sessions WHERE refreshed_at < ?').bind(cutoffMs).run(); + } +} + interface ModelAliasRow { name: string; kind: string; @@ -1763,6 +1825,7 @@ export class SqlRepo implements Repo { modelAliases: ModelAliasesRepo; responsesItems: ResponsesItemsRepo; responsesSnapshots: ResponsesSnapshotsRepo; + cursorSessions: CursorSessionsRepo; constructor(db: SqlDatabase) { this.users = new SqlUsersRepo(db); @@ -1779,5 +1842,6 @@ export class SqlRepo implements Repo { this.modelAliases = new SqlModelAliasesRepo(db); this.responsesItems = new SqlResponsesItemsRepo(db); this.responsesSnapshots = new SqlResponsesSnapshotsRepo(db); + this.cursorSessions = new SqlCursorSessionsRepo(db); } } diff --git a/packages/gateway/src/repo/types.ts b/packages/gateway/src/repo/types.ts index 73ad4c47b..13c0a2839 100644 --- a/packages/gateway/src/repo/types.ts +++ b/packages/gateway/src/repo/types.ts @@ -375,4 +375,24 @@ export interface Repo { modelAliases: ModelAliasesRepo; responsesItems: ResponsesItemsRepo; responsesSnapshots: ResponsesSnapshotsRepo; + cursorSessions: CursorSessionsRepo; +} + +// Cross-instance Cursor session scalars (see migration 0049_cursor_sessions.sql +// and packages/provider/src/repo.ts CursorSessionsRepoSlim). The full repo adds +// the cron sweep; the read/claim/put/delete surface is structurally the slim +// interface the provider package consumes. +export interface CursorSessionRow { + sessionKey: string; + requestId: string; + appendSeqno: number; + leftover: Uint8Array | null; +} + +export interface CursorSessionsRepo { + claim(sessionKey: string, ttlMs: number): Promise; + put(row: CursorSessionRow): Promise; + delete(sessionKey: string): Promise; + /** Cron sweep: drop rows whose refreshed_at is older than the cutoff (unix ms). */ + deleteOlderThan(cutoffMs: number): Promise; } diff --git a/packages/gateway/src/scheduled.ts b/packages/gateway/src/scheduled.ts index 8771aab22..1c3223938 100644 --- a/packages/gateway/src/scheduled.ts +++ b/packages/gateway/src/scheduled.ts @@ -7,6 +7,12 @@ import { getImageCacheStore } from '@floway-dev/platform'; // by it — a row stays referenceable until cleanup removes it. const RESPONSES_ITEM_ROW_TTL_MS = 180 * 24 * 60 * 60 * 1000; +// Cursor session scalars track a LIVE conversation turn (the read stream is +// held for at most the DurableHttpSession idle/cap window), so they expire far +// faster than durable history — a stale row only ever causes a clean +// cold-resume, never corruption. +const CURSOR_SESSION_ROW_TTL_MS = 30 * 60 * 1000; + const runSweep = async (name: string, fn: () => Promise): Promise => { try { await fn(); @@ -21,6 +27,7 @@ export const runScheduledMaintenance = async (): Promise => { await runSweep('responsesItems.sweepPayloadFiles', () => sweepExpiredResponsesItemPayloadFiles(now)); await runSweep('responsesSnapshots.deleteOlderThan', () => getRepo().responsesSnapshots.deleteOlderThan(now - RESPONSES_ITEM_ROW_TTL_MS)); await runSweep('responsesItems.deleteOlderThan', () => getRepo().responsesItems.deleteOlderThan(now - RESPONSES_ITEM_ROW_TTL_MS)); + await runSweep('cursorSessions.deleteOlderThan', () => getRepo().cursorSessions.deleteOlderThan(Date.now() - CURSOR_SESSION_ROW_TTL_MS)); await runSweep('imageCacheStore.sweepExpired', () => getImageCacheStore().sweepExpired(Date.now())); await runSweep('dumps.sweepExpired', () => sweepExpiredDumps()); }; diff --git a/packages/platform/src/durable-http-session.ts b/packages/platform/src/durable-http-session.ts new file mode 100644 index 000000000..3282dddb5 --- /dev/null +++ b/packages/platform/src/durable-http-session.ts @@ -0,0 +1,193 @@ +// Cross-request HTTP response holder. Each runtime supplies a concrete impl +// via initDurableHttpSession; callers obtain it via getDurableHttpSession(). +// +// Distinct from SocketDial in two ways: +// 1. Lifetime: SocketDial returns a connection valid for one request; +// DurableHttpSession keeps a request/response pair alive across many +// inbound HTTP requests until idle timeout or explicit discard. +// 2. Granularity: SocketDial deals in bytes; DurableHttpSession deals in +// an in-progress HTTP/1.1 response (status + headers + still-streaming +// body). Protocol decoding stays with the caller. +// +// Use case: providers whose upstream protocol needs a single long-lived +// response body that outlives the inbound HTTP request that triggered it — +// the same logical conversation continues via later inbound requests that +// piggy-back tool results onto the still-open upstream read. + +export interface DurableHttpSessionInit { + method: 'POST' | 'GET' | 'PUT' | 'DELETE'; + url: string; + headers: Record; + body?: Uint8Array; + /** + * Ordered proxy configs (opaque `@floway-dev/proxy` ProxyConfig) to dial the + * upstream through, tried in order; empty/absent = direct. The contract keeps + * this opaque so the platform package doesn't depend on @floway-dev/proxy — + * the concrete impls cast and call runProxiedRequest. Streaming responses + * can't go through the gateway's buffered proxy Fetcher, so the session + * does its own proxied dial. + */ + proxies?: unknown[]; +} + +export interface DurableHttpSessionHandle { + /** Response status from the upstream. */ + readonly status: number; + /** Response headers from the upstream. */ + readonly headers: Headers; + /** + * The response body stream. Each acquire returns a fresh ReadableStream + * view; releasing this handle does NOT cancel the underlying upstream + * read — the next acquire continues from wherever the stream is. Calling + * cancel() on this stream releases this consumer's hold but the + * underlying read keeps flowing into the broker's buffer. + */ + readonly body: ReadableStream; + /** + * Release this handle's hold on the session. The session itself stays + * alive in the pool for the next acquire. Idempotent. + */ + release(): Promise; + /** + * Forcibly close the underlying upstream connection and evict the + * session from the pool. Idempotent. Use when the protocol layer + * detects an unrecoverable state (framing error, upstream RST, etc.) + * so the next acquire must start a fresh upstream request. + */ + discard(reason: string): Promise; +} + +export interface DurableHttpSessionAcquireOptions { + /** + * Idle TTL — when no acquire holds the session for this many ms, the + * impl closes the upstream connection and evicts the entry. Default + * is impl-defined (Node InProcess uses 5 min; CF DO uses an alarm). + */ + idleTimeoutMs?: number; + /** + * Caller-supplied cancellation. Aborting the signal releases this + * acquire's handle. It does NOT discard the session — sibling acquires + * keep working. + */ + signal?: AbortSignal; +} + +export interface DurableHttpSession { + /** + * Acquire a handle to the session keyed by sessionKey. + * + * Semantics: + * - hit + init=null: return a handle to the existing session + * - hit + init non-null: ignore init (existing session wins; callers + * wanting to force a new upstream request must + * discard first) + * - miss + init non-null: open a new upstream request and seed the + * session before returning a handle + * - miss + init=null: return null (caller decides degradation — + * typically "treat as new conversation") + * + * Concurrent acquires for the same sessionKey are serialized inside the + * impl (Node uses a promise lock; CF DO actor is single-threaded by + * runtime). Callers do not need to synchronize. + */ + acquire( + sessionKey: string, + init: DurableHttpSessionInit | null, + opts?: DurableHttpSessionAcquireOptions, + ): Promise; +} + +let current: DurableHttpSession | null = null; + +export const initDurableHttpSession = (impl: DurableHttpSession): void => { + current = impl; +}; + +export const getDurableHttpSession = (): DurableHttpSession => { + if (!current) throw new Error('DurableHttpSession not initialized — call initDurableHttpSession() first'); + return current; +}; + +/** Test-only: clears the module singleton. */ +export const resetDurableHttpSessionForTesting = (): void => { + current = null; +}; + +// --------------------------------------------------------------------------- +// In-memory test fake. Same colocation convention as MemoryFileProvider in +// file-provider.ts — the fake travels with the contract so any consumer that +// imports the contract can also import the fake without a peer dependency. +// --------------------------------------------------------------------------- + +/** + * Scripted body for a fake session: an async iterable of byte chunks the + * fake will hand out through the body stream, plus the status/headers it + * reports. The same script is replayed for each fresh-create acquire; an + * acquire that hits an existing session yields a new ReadableStream + * draining what is left of the script. + */ +export interface FakeDurableHttpSessionScript { + status: number; + headers: Record; + /** Each yielded chunk is one ReadableStream enqueue. */ + body: AsyncIterable | Iterable; +} + +/** + * In-memory DurableHttpSession for unit tests. Captures every acquire/ + * release/discard with full init so tests can assert what the provider + * asked the broker to do, and reproduces multi-turn session reuse: an + * acquire(key, null) after a prior acquire(key, init) hits the cached + * entry and replays the same script. + */ +export class FakeDurableHttpSession implements DurableHttpSession { + /** Override what script to return for a given sessionKey. Set before the test runs. */ + readonly scripts = new Map(); + /** Audit log of every acquire call, in order. */ + readonly acquired: Array<{ sessionKey: string; init: DurableHttpSessionInit | null }> = []; + /** Audit log of every release call, in order. */ + readonly released: string[] = []; + /** Audit log of every discard call, in order. */ + readonly discarded: Array<{ sessionKey: string; reason: string }> = []; + + private readonly entries = new Map(); + + async acquire( + sessionKey: string, + init: DurableHttpSessionInit | null, + ): Promise { + this.acquired.push({ sessionKey, init }); + + let script = this.entries.get(sessionKey); + if (!script) { + if (init === null) return null; // miss + no init → caller must degrade + const seed = this.scripts.get(sessionKey); + if (!seed) { + throw new Error(`FakeDurableHttpSession: no script registered for sessionKey ${sessionKey}`); + } + script = seed; + this.entries.set(sessionKey, script); + } + + const body = new ReadableStream({ + async start(controller): Promise { + for await (const chunk of script.body) controller.enqueue(chunk); + controller.close(); + }, + }); + + const self = this; + return { + status: script.status, + headers: new Headers(script.headers), + body, + async release(): Promise { + self.released.push(sessionKey); + }, + async discard(reason: string): Promise { + self.discarded.push({ sessionKey, reason }); + self.entries.delete(sessionKey); + }, + }; + } +} diff --git a/packages/platform/src/durable-http-session_test.ts b/packages/platform/src/durable-http-session_test.ts new file mode 100644 index 000000000..afe091498 --- /dev/null +++ b/packages/platform/src/durable-http-session_test.ts @@ -0,0 +1,111 @@ +import { describe, expect, it, beforeEach } from 'vitest'; + +import { + FakeDurableHttpSession, + getDurableHttpSession, + initDurableHttpSession, + resetDurableHttpSessionForTesting, + type DurableHttpSession, + type DurableHttpSessionHandle, +} from './durable-http-session.ts'; + +describe('DurableHttpSession singleton', () => { + beforeEach(() => { + resetDurableHttpSessionForTesting(); + }); + + it('throws when used before init', () => { + expect(() => getDurableHttpSession()).toThrow('DurableHttpSession not initialized'); + }); + + it('returns the registered impl after init', () => { + const fake: DurableHttpSession = { + acquire: async (): Promise => null, + }; + initDurableHttpSession(fake); + expect(getDurableHttpSession()).toBe(fake); + }); + + it('overwrites the previously registered impl on re-init', () => { + const first: DurableHttpSession = { acquire: async () => null }; + const second: DurableHttpSession = { acquire: async () => null }; + initDurableHttpSession(first); + initDurableHttpSession(second); + expect(getDurableHttpSession()).toBe(second); + }); + + it('resets to uninitialized after resetDurableHttpSessionForTesting()', () => { + initDurableHttpSession({ acquire: async () => null }); + expect(getDurableHttpSession()).toBeDefined(); + resetDurableHttpSessionForTesting(); + expect(() => getDurableHttpSession()).toThrow('DurableHttpSession not initialized'); + }); +}); + +describe('FakeDurableHttpSession', () => { + it('returns null on miss when init is null', async () => { + const fake = new FakeDurableHttpSession(); + const handle = await fake.acquire('key', null); + expect(handle).toBeNull(); + expect(fake.acquired).toEqual([{ sessionKey: 'key', init: null }]); + }); + + it('seeds a new entry from registered scripts on miss + init non-null', async () => { + const fake = new FakeDurableHttpSession(); + fake.scripts.set('key', { + status: 200, + headers: { 'content-type': 'application/octet-stream' }, + body: [new Uint8Array([1, 2, 3]), new Uint8Array([4, 5])], + }); + const handle = await fake.acquire('key', { method: 'POST', url: 'https://x', headers: {} }); + expect(handle).not.toBeNull(); + expect(handle!.status).toBe(200); + expect(handle!.headers.get('content-type')).toBe('application/octet-stream'); + + const chunks: Uint8Array[] = []; + const reader = handle!.body.getReader(); + while (true) { + const { done, value } = await reader.read(); + if (done) break; + chunks.push(value); + } + expect(chunks.map(c => Array.from(c))).toEqual([[1, 2, 3], [4, 5]]); + }); + + it('hits the cached entry on a subsequent acquire(key, null)', async () => { + const fake = new FakeDurableHttpSession(); + fake.scripts.set('key', { status: 201, headers: {}, body: [new Uint8Array([9])] }); + await fake.acquire('key', { method: 'POST', url: 'https://x', headers: {} }); + const handle = await fake.acquire('key', null); + expect(handle).not.toBeNull(); + expect(handle!.status).toBe(201); + }); + + it('records release and discard calls in order', async () => { + const fake = new FakeDurableHttpSession(); + fake.scripts.set('a', { status: 200, headers: {}, body: [] }); + fake.scripts.set('b', { status: 200, headers: {}, body: [] }); + const a = await fake.acquire('a', { method: 'GET', url: 'https://x', headers: {} }); + const b = await fake.acquire('b', { method: 'GET', url: 'https://x', headers: {} }); + await a!.release(); + await b!.discard('protocol error'); + expect(fake.released).toEqual(['a']); + expect(fake.discarded).toEqual([{ sessionKey: 'b', reason: 'protocol error' }]); + }); + + it('discard evicts the entry so the next acquire(key, null) returns null', async () => { + const fake = new FakeDurableHttpSession(); + fake.scripts.set('k', { status: 200, headers: {}, body: [] }); + const h = await fake.acquire('k', { method: 'GET', url: 'https://x', headers: {} }); + await h!.discard('done'); + const next = await fake.acquire('k', null); + expect(next).toBeNull(); + }); + + it('throws when acquiring with init for a key that has no registered script', async () => { + const fake = new FakeDurableHttpSession(); + await expect( + fake.acquire('unknown', { method: 'GET', url: 'https://x', headers: {} }), + ).rejects.toThrow('no script registered'); + }); +}); diff --git a/packages/platform/src/index.ts b/packages/platform/src/index.ts index 35457993f..cf350aafb 100644 --- a/packages/platform/src/index.ts +++ b/packages/platform/src/index.ts @@ -1,4 +1,5 @@ export * from './background.ts'; +export * from './durable-http-session.ts'; export * from './env.ts'; export * from './file-provider.ts'; export * from './image-cache-store.ts'; diff --git a/packages/provider-claude-code/src/fetch_test.ts b/packages/provider-claude-code/src/fetch_test.ts index 25c1a5c98..fa1d5b814 100644 --- a/packages/provider-claude-code/src/fetch_test.ts +++ b/packages/provider-claude-code/src/fetch_test.ts @@ -9,7 +9,7 @@ import type { ClaudeCodeUpstreamState, } from './state.ts'; import { initProviderRepo, type Fetcher, type UpstreamCallOptions, type UpstreamRecord } from '@floway-dev/provider'; -import { noopUpstreamCallOptions, stubProviderModel } from '@floway-dev/test-utils'; +import { noopCursorSessionsRepo, noopUpstreamCallOptions, stubProviderModel } from '@floway-dev/test-utils'; const upstreamId = 'up_cc'; @@ -96,6 +96,7 @@ beforeEach(() => { return { updated: true }; }, }, + cursorSessions: noopCursorSessionsRepo(), })); }); diff --git a/packages/provider-claude-code/src/provider_test.ts b/packages/provider-claude-code/src/provider_test.ts index 69ff68d26..be0325b33 100644 --- a/packages/provider-claude-code/src/provider_test.ts +++ b/packages/provider-claude-code/src/provider_test.ts @@ -5,7 +5,7 @@ import { pricingForClaudeCodeModelKey } from './pricing.ts'; import { createClaudeCodeProvider } from './provider.ts'; import type { ClaudeCodeAccessTokenEntry, ClaudeCodeAccountCredential, ClaudeCodeUpstreamState } from './state.ts'; import { initProviderRepo, type UpstreamCallOptions, type UpstreamRecord } from '@floway-dev/provider'; -import { noopUpstreamCallOptions } from '@floway-dev/test-utils'; +import { noopCursorSessionsRepo, noopUpstreamCallOptions } from '@floway-dev/test-utils'; const upstreamId = 'up_cc_provider'; @@ -68,6 +68,7 @@ beforeEach(() => { return { updated: true }; }, }, + cursorSessions: noopCursorSessionsRepo(), })); }); diff --git a/packages/provider-codex/src/access-token-cache_test.ts b/packages/provider-codex/src/access-token-cache_test.ts index f1f4aab14..f0f731fc3 100644 --- a/packages/provider-codex/src/access-token-cache_test.ts +++ b/packages/provider-codex/src/access-token-cache_test.ts @@ -10,6 +10,7 @@ import { import { CodexOAuthSessionTerminatedError } from './auth/oauth.ts'; import type { CodexUpstreamState } from './state.ts'; import { initProviderRepo, type UpstreamRecord } from '@floway-dev/provider'; +import { noopCursorSessionsRepo } from '@floway-dev/test-utils'; const accountId = 'acc_1'; const upstreamId = 'up_a'; @@ -55,6 +56,7 @@ beforeEach(() => { }); getByIdSpy = vi.fn(async () => current); initProviderRepo(() => ({ + cursorSessions: noopCursorSessionsRepo(), upstreams: { getById: getByIdSpy, saveState: saveStateSpy }, })); }); diff --git a/packages/provider-codex/src/fetch_test.ts b/packages/provider-codex/src/fetch_test.ts index f161b393f..bcccf96c2 100644 --- a/packages/provider-codex/src/fetch_test.ts +++ b/packages/provider-codex/src/fetch_test.ts @@ -5,7 +5,7 @@ import { callCodexResponses, callCodexResponsesCompact, type CodexCallEffects } import type { CodexAccessTokenEntry, CodexAccountCredential, CodexQuotaSnapshotEntry, CodexUpstreamState } from './state.ts'; import type { ResponsesResult } from '@floway-dev/protocols/responses'; import { initProviderRepo, type Fetcher, type UpstreamRecord } from '@floway-dev/provider'; -import { noopUpstreamCallOptions, stubProviderModel } from '@floway-dev/test-utils'; +import { noopCursorSessionsRepo, noopUpstreamCallOptions, stubProviderModel } from '@floway-dev/test-utils'; const makeEffects = (): CodexCallEffects => ({ persistRefreshTokenRotation: vi.fn(async () => {}), @@ -62,6 +62,7 @@ beforeEach(() => { vi.useRealTimers(); currentRecord = makeRecord({ accounts: [{ ...activeAccount }] }); initProviderRepo(() => ({ + cursorSessions: noopCursorSessionsRepo(), upstreams: { getById: async () => currentRecord, saveState: async (_id, newState) => { @@ -752,6 +753,7 @@ const enforcingRecorder = () => { recordUpstreamLatency: record, waitUntil: () => {}, headers: new Headers(), + apiKeyId: 'test-api-key', }, invocations: () => wrappedPromises.length, durationMs: (): number => { diff --git a/packages/provider-codex/src/interceptors/responses/action-pivot_test.ts b/packages/provider-codex/src/interceptors/responses/action-pivot_test.ts index 8e4239e5b..43c90292e 100644 --- a/packages/provider-codex/src/interceptors/responses/action-pivot_test.ts +++ b/packages/provider-codex/src/interceptors/responses/action-pivot_test.ts @@ -30,7 +30,7 @@ vi.mock('./index.ts', async () => { // Imports below MUST follow the vi.mock so the provider module resolves // against the mocked chain on first import. const { createCodexProvider } = await import('../../provider.ts'); -const { noopUpstreamCallOptions, stubProviderModel } = await import('@floway-dev/test-utils'); +const { noopCursorSessionsRepo, noopUpstreamCallOptions, stubProviderModel } = await import('@floway-dev/test-utils'); const farFutureMs = Date.now() + 24 * 60 * 60 * 1000; @@ -56,6 +56,7 @@ beforeEach(() => { getById: async () => baseRecord, saveState: async () => ({ updated: true }), }, + cursorSessions: noopCursorSessionsRepo(), })); }); diff --git a/packages/provider-codex/src/provider_test.ts b/packages/provider-codex/src/provider_test.ts index 927cac536..bc0ea9c76 100644 --- a/packages/provider-codex/src/provider_test.ts +++ b/packages/provider-codex/src/provider_test.ts @@ -3,7 +3,7 @@ import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; import { createCodexProvider } from './provider.ts'; import type { CodexAccessTokenEntry, CodexUpstreamState } from './state.ts'; import { directFetcher, initProviderRepo, type UpstreamRecord } from '@floway-dev/provider'; -import { noopUpstreamCallOptions, stubProviderModel } from '@floway-dev/test-utils'; +import { noopCursorSessionsRepo, noopUpstreamCallOptions, stubProviderModel } from '@floway-dev/test-utils'; const farFutureMs = Date.now() + 24 * 60 * 60 * 1000; @@ -37,6 +37,7 @@ beforeEach(() => { saveStateSpy = vi.fn<(id: string, newState: unknown, options: { expectedState: unknown }) => Promise<{ updated: boolean }>>(async () => ({ updated: true })); getByIdSpy = vi.fn<(id: string) => Promise>(async () => recordWithAccessToken()); initProviderRepo(() => ({ + cursorSessions: noopCursorSessionsRepo(), upstreams: { getById: getByIdSpy, saveState: saveStateSpy }, })); }); diff --git a/packages/provider-codex/src/quota_test.ts b/packages/provider-codex/src/quota_test.ts index a508add3e..ef6d6a854 100644 --- a/packages/provider-codex/src/quota_test.ts +++ b/packages/provider-codex/src/quota_test.ts @@ -10,6 +10,7 @@ import { } from './quota.ts'; import type { CodexQuotaSnapshotEntry, CodexUpstreamState } from './state.ts'; import { initProviderRepo, type UpstreamRecord } from '@floway-dev/provider'; +import { noopCursorSessionsRepo } from '@floway-dev/test-utils'; const accountId = 'acc_1'; const upstreamId = 'up_a'; @@ -53,6 +54,7 @@ beforeEach(() => { getByIdSpy = vi.fn(async () => current); initProviderRepo(() => ({ upstreams: { getById: getByIdSpy, saveState: saveStateSpy }, + cursorSessions: noopCursorSessionsRepo(), })); }); diff --git a/packages/provider-copilot/src/auth_test.ts b/packages/provider-copilot/src/auth_test.ts index fec4511b9..e2eac3425 100644 --- a/packages/provider-copilot/src/auth_test.ts +++ b/packages/provider-copilot/src/auth_test.ts @@ -4,7 +4,7 @@ import { copilotAuthedFetch } from './auth.ts'; import { clearInProcessCopilotTokenCache } from './index.ts'; import type { CopilotUpstreamState } from './state.ts'; import { initProviderRepo, directFetcher, type UpstreamRecord } from '@floway-dev/provider'; -import { assertEquals, jsonResponse, withMockedFetch } from '@floway-dev/test-utils'; +import { assertEquals, jsonResponse, noopCursorSessionsRepo, withMockedFetch } from '@floway-dev/test-utils'; const UPSTREAM_ID = 'up_copilot_test'; const TOKEN_BASE_URL = 'https://api.individual.githubcopilot.com'; @@ -27,6 +27,7 @@ const installRepoAndClearCache = async () => { config: { githubToken: 'ghu_test', user: { id: 1, login: 't', name: null, avatar_url: '' } }, }; initProviderRepo(() => ({ + cursorSessions: noopCursorSessionsRepo(), upstreams: { getById: async () => ({ ...stub, state }), saveState: async (_id, newState) => { diff --git a/packages/provider-copilot/src/fetch-models_test.ts b/packages/provider-copilot/src/fetch-models_test.ts index 4a4b2c5d0..3a2b812f0 100644 --- a/packages/provider-copilot/src/fetch-models_test.ts +++ b/packages/provider-copilot/src/fetch-models_test.ts @@ -3,7 +3,7 @@ import { test } from 'vitest'; import { fetchCopilotModels } from './fetch-models.ts'; import { clearInProcessCopilotTokenCache } from './index.ts'; import { ProviderModelsUnavailableError, initProviderRepo, directFetcher, type UpstreamRecord } from '@floway-dev/provider'; -import { assertEquals, jsonResponse, withMockedFetch } from '@floway-dev/test-utils'; +import { assertEquals, jsonResponse, noopCursorSessionsRepo, withMockedFetch } from '@floway-dev/test-utils'; const installRepoAndConfig = async () => { const id = 'up_copilot_fetch_models_test'; @@ -24,6 +24,7 @@ const installRepoAndConfig = async () => { config: { githubToken, user: { id: 1, login: 't', name: null, avatar_url: '' } }, }; initProviderRepo(() => ({ + cursorSessions: noopCursorSessionsRepo(), upstreams: { getById: async () => stub, saveState: async () => ({ updated: true }), diff --git a/packages/provider-copilot/src/interceptors/responses/action-pivot_test.ts b/packages/provider-copilot/src/interceptors/responses/action-pivot_test.ts index 552d0da92..ce800c74e 100644 --- a/packages/provider-copilot/src/interceptors/responses/action-pivot_test.ts +++ b/packages/provider-copilot/src/interceptors/responses/action-pivot_test.ts @@ -33,7 +33,7 @@ const { clearInProcessCopilotTokenCache } = await import('../../auth.ts'); const { createCopilotProvider } = await import('../../provider.ts'); const { createInMemoryImageProcessor, initImageProcessor } = await import('@floway-dev/platform'); const { directFetcher, initProviderRepo } = await import('@floway-dev/provider'); -const { jsonResponse, noopUpstreamCallOptions, sseResponse, withMockedFetch } = await import('@floway-dev/test-utils'); +const { jsonResponse, noopCursorSessionsRepo, noopUpstreamCallOptions, sseResponse, withMockedFetch } = await import('@floway-dev/test-utils'); type UpstreamRecord = import('@floway-dev/provider').UpstreamRecord; test('Copilot provider terminal dispatches on post-chain ctx.action (interceptor flip compact→generate routes to the streaming generate path)', async () => { @@ -60,6 +60,7 @@ test('Copilot provider terminal dispatches on post-chain ctx.action (interceptor getById: async () => upstream, saveState: async () => ({ updated: true }), }, + cursorSessions: noopCursorSessionsRepo(), })); initImageProcessor(createInMemoryImageProcessor()); clearInProcessCopilotTokenCache(); diff --git a/packages/provider-copilot/src/provider_test.ts b/packages/provider-copilot/src/provider_test.ts index 8c522b891..fd6b87336 100644 --- a/packages/provider-copilot/src/provider_test.ts +++ b/packages/provider-copilot/src/provider_test.ts @@ -8,7 +8,7 @@ import { createInMemoryImageProcessor, initImageProcessor } from '@floway-dev/pl import type { MessagesPayload } from '@floway-dev/protocols/messages'; import type { UpstreamRecord } from '@floway-dev/provider'; import { directFetcher, initProviderRepo } from '@floway-dev/provider'; -import { assertEquals, assertRejects, jsonResponse, noopUpstreamCallOptions, sseResponse, withMockedFetch } from '@floway-dev/test-utils'; +import { assertEquals, assertRejects, jsonResponse, noopCursorSessionsRepo, noopUpstreamCallOptions, sseResponse, withMockedFetch } from '@floway-dev/test-utils'; const buildCopilotUpstream = (overrides: Partial = {}): UpstreamRecord => { const { config: overrideConfig, ...rest } = overrides; @@ -72,6 +72,7 @@ const setupCopilotTest = async (initial: SetupOptions = {}): Promise ({ + cursorSessions: noopCursorSessionsRepo(), upstreams: { getById: () => getByIdImpl(), saveState: (id, newState, options) => saveStateImpl(id, newState, options), diff --git a/packages/provider-cursor/package.json b/packages/provider-cursor/package.json new file mode 100644 index 000000000..518560be3 --- /dev/null +++ b/packages/provider-cursor/package.json @@ -0,0 +1,22 @@ +{ + "name": "@floway-dev/provider-cursor", + "private": true, + "version": "0.0.0", + "type": "module", + "exports": { + ".": { "import": "./src/index.ts", "types": "./src/index.ts" } + }, + "scripts": { + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "@floway-dev/http": "workspace:*", + "@floway-dev/interceptor": "workspace:*", + "@floway-dev/platform": "workspace:*", + "@floway-dev/protocols": "workspace:*", + "@floway-dev/provider": "workspace:*" + }, + "devDependencies": { + "@floway-dev/test-utils": "workspace:*" + } +} \ No newline at end of file diff --git a/packages/provider-cursor/src/access-token-cache.ts b/packages/provider-cursor/src/access-token-cache.ts new file mode 100644 index 000000000..9b7b1da50 --- /dev/null +++ b/packages/provider-cursor/src/access-token-cache.ts @@ -0,0 +1,122 @@ +import { CursorSessionTerminatedError, refreshCursorAccessToken } from './auth/oauth.ts'; +import { readCursorUpstreamState, type CursorAccessTokenEntry, type CursorUpstreamState } from './state.ts'; +import { getProviderRepo, type Fetcher } from '@floway-dev/provider'; + +export type { CursorAccessTokenEntry }; + +// Refresh window: a cached token within this much of expiry counts as +// already-expired so the next call mints a fresh one rather than racing the +// upstream clock. +const REFRESH_SKEW_MS = 5 * 60 * 1000; + +const isAccessTokenFresh = (entry: CursorAccessTokenEntry): boolean => + entry.expiresAt > Date.now() + REFRESH_SKEW_MS; + +const findAccountIndex = (state: CursorUpstreamState, userId: string): number => + state.accounts.findIndex(a => a.userId === userId); + +const replaceAccountAccessToken = ( + state: CursorUpstreamState, + index: number, + entry: CursorAccessTokenEntry | null, +): CursorUpstreamState => ({ + ...state, + accounts: state.accounts.map((account, i) => (i === index ? { ...account, accessToken: entry } : account)), +}); + +// A losing CAS is not an error — saveState reports it via `updated: false`, +// and the next call re-reads state and refreshes if needed. Genuine storage +// failures propagate. +const persistAccessToken = async ( + upstreamId: string, + userId: string, + entry: CursorAccessTokenEntry | null, + where: string, +): Promise => { + const fresh = await getProviderRepo().upstreams.getById(upstreamId); + if (!fresh) { + console.warn(`${where}: Cursor upstream ${upstreamId} disappeared mid-request`); + return; + } + const state = readCursorUpstreamState(fresh.state); + const idx = findAccountIndex(state, userId); + if (idx < 0) { + console.warn(`${where}: Cursor account ${userId} not found in upstream ${upstreamId}`); + return; + } + if (entry === null && state.accounts[idx]!.accessToken === null) return; + const next = replaceAccountAccessToken(state, idx, entry); + await getProviderRepo().upstreams.saveState(upstreamId, next, { expectedState: fresh.state }); +}; + +// Reads, mints, and persists. Refresh-race recovery: Cursor's refresh endpoint +// returns 401/403 for both a genuinely dead refresh_token AND a sibling-won +// rotation (our copy is now stale). recoverFromRefreshRace re-reads state and +// compares the refresh token; if a sibling rotated, we use their cached access +// token. If nothing moved, the session is really dead and we re-raise. +export const ensureCursorAccessToken = async ( + upstreamId: string, + userId: string, + mint: (refreshToken: string) => Promise, + recoveryAllowed = true, +): Promise => { + const fresh = await getProviderRepo().upstreams.getById(upstreamId); + if (!fresh) throw new Error(`Cursor upstream ${upstreamId} not found`); + const state = readCursorUpstreamState(fresh.state); + const account = state.accounts.find(a => a.userId === userId); + if (!account) throw new Error(`Cursor account ${userId} not found in upstream ${upstreamId}`); + if (account.accessToken && isAccessTokenFresh(account.accessToken)) { + return account.accessToken; + } + + let minted; + try { + minted = await mint(account.refresh_token); + } catch (err) { + if (err instanceof CursorSessionTerminatedError && recoveryAllowed) { + const recovered = await recoverFromRefreshRace(upstreamId, userId, account.refresh_token, mint); + if (recovered) return recovered; + } + throw err; + } + await persistAccessToken(upstreamId, userId, minted, 'ensureCursorAccessToken'); + return minted; +}; + +const recoverFromRefreshRace = async ( + upstreamId: string, + userId: string, + usedRefreshToken: string, + mint: (refreshToken: string) => Promise, +): Promise => { + const reread = await getProviderRepo().upstreams.getById(upstreamId); + if (!reread) return null; + const rereadState = readCursorUpstreamState(reread.state); + const rereadAccount = rereadState.accounts.find(a => a.userId === userId); + if (!rereadAccount) return null; + if (rereadAccount.state !== 'active') return null; + if (rereadAccount.refresh_token === usedRefreshToken) return null; + console.info( + `Cursor refresh-race recovered for upstream ${upstreamId} account ${userId}: sibling rotated, using their access token`, + ); + if (rereadAccount.accessToken && isAccessTokenFresh(rereadAccount.accessToken)) { + return rereadAccount.accessToken; + } + return await ensureCursorAccessToken(upstreamId, userId, mint, false); +}; + +// Mints a fresh access token via /auth/exchange_user_api_key and routes the +// rotated refresh_token through the caller's CAS hook. +export const mintCursorAccessToken = async ( + refreshToken: string, + fetcher: Fetcher, + persistRefreshTokenRotation: (newRefreshToken: string) => Promise, +): Promise => { + const tokens = await refreshCursorAccessToken(refreshToken, fetcher); + await persistRefreshTokenRotation(tokens.refresh_token); + return { + token: tokens.access_token, + expiresAt: tokens.expires_at, + refreshedAt: new Date().toISOString(), + }; +}; diff --git a/packages/provider-cursor/src/agent-translate.ts b/packages/provider-cursor/src/agent-translate.ts new file mode 100644 index 000000000..986eba600 --- /dev/null +++ b/packages/provider-cursor/src/agent-translate.ts @@ -0,0 +1,327 @@ +/** + * Cursor Agent → OpenAI Chat Completions stream translator. + * + * Consumes AgentStreamChunk events from agent-transport and emits + * ChatCompletionsStreamEvent frames. The caller (fetch.ts) wraps each event + * with eventFrame/doneFrame into a ProviderStreamResult. + * + * Composer thinking-as-content: the composer-* family puts the visible reply + * inside the `thinking` field after a final `` sentinel, optionally + * wrapped in `<|final|>`/`<|final|>` tags. The chain-of-thought prefix is + * hidden; only the suffix is surfaced as `content`. Non-composer models map + * `thinking` straight to `reasoning_text`. + */ + +import type { AgentStreamChunk, ExecRequest } from './proto/index.ts'; +import { wrapToolCallId } from './session-id.ts'; +import type { ChatCompletionsStreamEvent } from '@floway-dev/protocols/chat-completions'; + +const COMPOSER_THINK_END = ''; + +const COMPOSER_OPEN_MARKER = /^\s*<[||]\s*final\s*[||]>\s*/i; +const COMPOSER_CLOSE_MARKER = /\s*<[||]\s*\/\s*final\s*[||]>\s*$/i; +const COMPOSER_PARTIAL_OPEN = /^\s*<(?![||/])/; +const COMPOSER_PARTIAL_OPEN_PIPE = /^\s*<[||][^>]*$/; + +export function isComposerModel(model: string | undefined | null): boolean { + const id = String(model ?? '') + .split('/') + .pop(); + return /^composer(?:-|$)/i.test(id ?? ''); +} + +/** + * Extract the user-facing reply from an accumulated composer thinking buffer: + * everything after the last ``, with optional `<|final|>` wrapper + * stripped. Returns "" until the sentinel has arrived (so partial marker + * fragments don't leak as content). + */ +export function visibleComposerContentFromThinking(thinking: string): string { + if (!thinking) return ''; + const endIdx = thinking.lastIndexOf(COMPOSER_THINK_END); + if (endIdx < 0) return ''; + let visible = thinking.slice(endIdx + COMPOSER_THINK_END.length).trimStart(); + if (COMPOSER_OPEN_MARKER.test(visible)) { + visible = visible.replace(COMPOSER_OPEN_MARKER, ''); + } else if (COMPOSER_PARTIAL_OPEN.test(visible) || COMPOSER_PARTIAL_OPEN_PIPE.test(visible)) { + return ''; + } + return visible.replace(COMPOSER_CLOSE_MARKER, '').trim(); +} + +export interface TranslatorOptions { + id: string; + model: string; + created: number; + composer?: boolean; + /** + * When set, tool_call_id values emitted in tool_calls deltas are wrapped + * with `sess___` so the OpenAI client echoes them back on the + * next turn, enabling session correlation. Leave undefined to emit cursor's + * raw tool_call_id (backward-compatible, no session persistence). + */ + sessionKey?: string; +} + +export interface AgentTranslator { + /** Map one transport chunk to 0..N stream events. */ + translate(chunk: AgentStreamChunk): ChatCompletionsStreamEvent[]; + /** Emit the terminal finish_reason chunk. Idempotent. */ + finalize(): ChatCompletionsStreamEvent[]; + /** + * The model's context window as reported by the run's last checkpoint + * (ConversationTokenDetails.maxTokens), or null if no checkpoint carried it. + * The caller persists this into the per-model context cache. + */ + observedMaxTokens(): number | null; +} + +type FinishReason = 'stop' | 'length' | 'tool_calls' | 'content_filter'; + +export function createAgentTranslator(opts: TranslatorOptions): AgentTranslator { + const composer = opts.composer ?? isComposerModel(opts.model); + + // Cursor packs two ids into one toolCallId string, separated by a newline: + // an OpenAI-style `call_…` token first, then a Responses-API-style `fc_…` + // token. OpenAI clients expect a single id with no embedded whitespace, so + // we surface only the leading call_… form. Used for built-in tool calls, + // which are never resumed; the MCP path (translateExecRequest) instead wraps + // the exec ref via wrapToolCallId so a follow-up can rebuild the result. + const cleanCallId = (raw: string): string => raw.split('\n')[0]!.trim(); + + let emittedRole = false; + let toolCallIndex = 0; + let currentToolCallIndex = 0; + let emittedToolCalls = 0; + let endReason: 'tool_calls' | null = null; + let finalized = false; + + // Composer thinking-as-content accumulation. + let thinkingText = ''; + let composerVisibleEmittedLength = 0; + + // Per-request token accounting recovered from the RunSSE stream: cursor's + // TokenDeltaUpdate increments summed into the output count, and the latest + // ConversationTokenDetails.usedTokens as the authoritative context total. + let outputTokens = 0; + let contextUsedTokens: number | null = null; + // The model's context window (ConversationTokenDetails.maxTokens), surfaced + // to the caller for the per-model context cache. + let contextMaxTokens: number | null = null; + + const makeEvent = ( + delta: NonNullable, + finishReason: FinishReason | null = null, + ): ChatCompletionsStreamEvent => { + if (!emittedRole) { + delta = { role: 'assistant', ...delta }; + emittedRole = true; + } + return { + id: opts.id, + object: 'chat.completion.chunk', + created: opts.created, + model: opts.model, + choices: [{ index: 0, delta, finish_reason: finishReason }], + }; + }; + + const emitToolCallStart = (index: number, id: string, name: string): ChatCompletionsStreamEvent => { + return makeEvent({ + tool_calls: [{ index, id, type: 'function', function: { name } }], + }); + }; + + const translate = (chunk: AgentStreamChunk): ChatCompletionsStreamEvent[] => { + switch (chunk.type) { + case 'text': { + if (!chunk.content) return []; + return [makeEvent({ content: chunk.content })]; + } + + case 'kv_blob_assistant': { + if (!chunk.blobContent) return []; + return [makeEvent({ content: chunk.blobContent })]; + } + + case 'thinking': { + if (!chunk.content) return []; + if (!composer) { + return [makeEvent({ reasoning_text: chunk.content })]; + } + // Composer: accumulate, surface only the visible suffix incrementally. + thinkingText += chunk.content; + const visible = visibleComposerContentFromThinking(thinkingText); + if (visible.length <= composerVisibleEmittedLength) return []; + const delta = visible.slice(composerVisibleEmittedLength); + composerVisibleEmittedLength = visible.length; + return [makeEvent({ content: delta })]; + } + + case 'tool_call_started': { + const tc = chunk.toolCall; + if (!tc) return []; + // Cursor's tool_call_started + partial_tool_call + tool_call_completed + // for MCP tools are internal pre-warm signals: the toolCall.name is + // always "mcp" (the cursor TOOL_FIELD_MAP entry for mcp_tool_call) and + // the real user-facing tool name + args only land on the matching + // exec_request that follows. Translating these would double-emit each + // tool call with a placeholder name and break the OpenAI tool_calls + // contract (one delta per call, with the real name). Skip them and let + // exec_request own the translation. + if (tc.toolType === 'mcp_tool_call' || tc.name === 'mcp') return []; + const index = toolCallIndex++; + currentToolCallIndex = index; + emittedToolCalls++; + endReason = 'tool_calls'; + return [emitToolCallStart(index, cleanCallId(tc.callId), tc.name)]; + } + + case 'tool_call_completed': { + const tc = chunk.toolCall; + if (!tc) return []; + // Same rationale as tool_call_started: cursor's MCP completion echoes + // the internal "mcp" type; the authoritative completion is the + // exec_request that already produced the tool_calls delta. + if (tc.toolType === 'mcp_tool_call' || tc.name === 'mcp') return []; + // If no started chunk preceded it, claim a fresh index. + if (emittedToolCalls === 0) { + const index = toolCallIndex++; + currentToolCallIndex = index; + emittedToolCalls++; + endReason = 'tool_calls'; + } + return [ + makeEvent({ + tool_calls: [ + { + index: currentToolCallIndex, + id: cleanCallId(tc.callId), + type: 'function', + function: { name: tc.name, arguments: tc.arguments ?? '' }, + }, + ], + }), + ]; + } + + case 'partial_tool_call': { + // Skip until a real tool_call_started has run — without an index of our + // own, an early partial would attach to the wrong tool. Cursor's MCP + // partials are pre-warm noise (empty argsTextDelta) anyway; the real + // arguments arrive whole on exec_request. + if (emittedToolCalls === 0) return []; + return [ + makeEvent({ + tool_calls: [ + { + index: currentToolCallIndex, + function: { arguments: chunk.partialArgs ?? '' }, + }, + ], + }), + ]; + } + + case 'exec_request': { + return translateExecRequest(chunk.execRequest); + } + + case 'token': + // TokenDeltaUpdate increment — cursor's own output-token ticker. + outputTokens += chunk.tokens ?? 0; + return []; + + case 'checkpoint': + // ConversationTokenDetails on a conversation checkpoint: usedTokens is + // the live context occupancy (input + history + output-so-far). Keep the + // latest; it becomes the request's authoritative total_tokens. maxTokens + // is the model's context window, cached per-model by the caller. + if (typeof chunk.usedTokens === 'number' && chunk.usedTokens > 0) contextUsedTokens = chunk.usedTokens; + if (typeof chunk.maxTokens === 'number' && chunk.maxTokens > 0) contextMaxTokens = chunk.maxTokens; + return []; + + case 'heartbeat': + case 'interaction_query': + case 'exec_server_abort': + return []; + + case 'done': + case 'error': + // done/error are handled by the caller (finalize / error surfacing). + return []; + } + return []; + }; + + /** + * MCP exec requests become a downstream tool_calls delta (stateless passthru: + * we do NOT reply on the BidiAppend channel — the next turn inlines the tool + * result). Built-in tool exec requests are not translated here; the caller + * rejects them on the write channel so the model keeps streaming. + */ + const translateExecRequest = (execRequest: ExecRequest | undefined): ChatCompletionsStreamEvent[] => { + if (!execRequest) return []; + if (execRequest.type !== 'mcp') return []; + + const index = toolCallIndex++; + currentToolCallIndex = index; + emittedToolCalls++; + endReason = 'tool_calls'; + return [ + makeEvent({ + tool_calls: [ + { + index, + // Encode the session id + cursor exec ref into the tool_call_id so a + // follow-up (possibly cross-instance) rebuilds the ExecMcpResult from + // the client's echoed id. Falls back to the bare call id when no + // session is tracked (shouldn't happen for MCP turns). + id: opts.sessionKey + ? wrapToolCallId(opts.sessionKey, { id: execRequest.id, execId: execRequest.execId }) + : cleanCallId(execRequest.toolCallId), + type: 'function', + function: { + name: execRequest.toolName || execRequest.name, + arguments: JSON.stringify(execRequest.args ?? {}), + }, + }, + ], + }), + ]; + }; + + const finalize = (): ChatCompletionsStreamEvent[] => { + if (finalized) return []; + finalized = true; + const finishReason: FinishReason = endReason === 'tool_calls' ? 'tool_calls' : 'stop'; + // A turn is accountable only when a ConversationTokenDetails checkpoint + // arrived AND the turn didn't end on a tool-call pause. That checkpoint + // carries cursor's authoritative CUMULATIVE context total and only lands at + // the very end of a run, after the model's final answer: + // total_tokens = usedTokens (input + history + all output so far) + // completion_tokens = Σ TokenDeltaUpdate.tokens (this turn's output), + // clamped to total + // prompt_tokens = total − completion + // Tool-call turns pause at the exec_request before any checkpoint, so they + // report ALL-ZERO here; the real usage lands on the final turn whose + // checkpoint totals the whole run. A turn that ends with no checkpoint (a + // transient empty turn) is likewise all-zero. In every case the request is + // still counted — recordUsage logs the bare request row from the non-null + // usage object. + const total = finishReason !== 'tool_calls' && contextUsedTokens != null ? contextUsedTokens : 0; + const completion = total > 0 ? Math.min(outputTokens, total) : 0; + const prompt = total - completion; + const usageEvent: ChatCompletionsStreamEvent = { + id: opts.id, + object: 'chat.completion.chunk', + created: opts.created, + model: opts.model, + choices: [], + usage: { prompt_tokens: prompt, completion_tokens: completion, total_tokens: total }, + }; + return [makeEvent({}, finishReason), usageEvent]; + }; + + return { translate, finalize, observedMaxTokens: () => contextMaxTokens }; +} diff --git a/packages/provider-cursor/src/agent-translate_test.ts b/packages/provider-cursor/src/agent-translate_test.ts new file mode 100644 index 000000000..d36d0a8ed --- /dev/null +++ b/packages/provider-cursor/src/agent-translate_test.ts @@ -0,0 +1,241 @@ +import { describe, expect, test } from 'vitest'; + +import { + createAgentTranslator, + isComposerModel, + visibleComposerContentFromThinking, +} from './agent-translate.ts'; +import type { AgentStreamChunk, ExecRequest } from './proto/index.ts'; + +const mk = (model: string) => createAgentTranslator({ id: 'chatcmpl-1', model, created: 100 }); + +const deltaOf = (evs: ReturnType['translate']>[number]) => evs.choices[0]!.delta; +const finishOf = (evs: ReturnType['finalize']>[number]) => evs.choices[0]!.finish_reason; + +describe('isComposerModel', () => { + test.each([ + ['composer-2.5', true], + ['composer', true], + ['cursor/composer-2.5', true], + ['gpt-4o', false], + ['claude-3.7-sonnet', false], + ['', false], + ])('%s → %s', (model, expected) => { + expect(isComposerModel(model)).toBe(expected); + }); +}); + +describe('visibleComposerContentFromThinking', () => { + test('returns "" before the sentinel arrives', () => { + expect(visibleComposerContentFromThinking('chain of thought...')).toBe(''); + }); + test('returns the suffix after the last sentinel', () => { + expect(visibleComposerContentFromThinking('cot partvisible reply')).toBe('visible reply'); + }); + test('strips the <|final|> open marker', () => { + expect(visibleComposerContentFromThinking('cot<|final|>real answer')).toBe('real answer'); + }); + test('strips ASCII <|final|> markers', () => { + expect(visibleComposerContentFromThinking('cot<|final|>real<|/final|>')).toBe('real'); + }); + test('holds back a partial open marker', () => { + // Only a fragment of the marker arrived — must not leak as content yet. + expect(visibleComposerContentFromThinking('cot<|fi')).toBe(''); + }); +}); + +describe('createAgentTranslator — text', () => { + test('emits content with role on the first chunk', () => { + const t = mk('gpt-4o'); + const evs = t.translate({ type: 'text', content: 'hi' }); + expect(evs).toHaveLength(1); + expect(deltaOf(evs[0]!)).toEqual({ role: 'assistant', content: 'hi' }); + expect(evs[0]!.choices[0]!.finish_reason).toBeNull(); + }); + + test('subsequent text chunks omit role', () => { + const t = mk('gpt-4o'); + t.translate({ type: 'text', content: 'a' }); + const evs = t.translate({ type: 'text', content: 'b' }); + expect(deltaOf(evs[0]!)).toEqual({ content: 'b' }); + }); + + test('kv_blob_assistant maps to content', () => { + const t = mk('gpt-4o'); + const evs = t.translate({ type: 'kv_blob_assistant', blobContent: 'blob reply' }); + expect(deltaOf(evs[0]!)).toEqual({ role: 'assistant', content: 'blob reply' }); + }); +}); + +describe('createAgentTranslator — thinking', () => { + test('non-composer maps thinking to reasoning_text', () => { + const t = mk('claude-3.7-sonnet'); + const evs = t.translate({ type: 'thinking', content: 'reasoning...' }); + expect(deltaOf(evs[0]!)).toEqual({ role: 'assistant', reasoning_text: 'reasoning...' }); + }); + + test('composer hides cot and surfaces visible suffix incrementally', () => { + const t = mk('composer-2.5'); + // Before sentinel: nothing visible yet. + expect(t.translate({ type: 'thinking', content: 'private cot' })).toEqual([]); + // Sentinel arrives with the visible reply. + const evs = t.translate({ type: 'thinking', content: 'hello world' }); + expect(deltaOf(evs[0]!).content).toBe('hello world'); + }); + + test('composer emits only the incremental tail on subsequent chunks', () => { + const t = mk('composer-2.5'); + t.translate({ type: 'thinking', content: 'cothello' }); + const evs = t.translate({ type: 'thinking', content: ' world' }); + expect(deltaOf(evs[0]!).content).toBe(' world'); + }); +}); + +describe('createAgentTranslator — tool calls', () => { + test('tool_call_started emits an indexed tool_calls delta', () => { + const t = mk('gpt-4o'); + const evs = t.translate({ + type: 'tool_call_started', + toolCall: { callId: 'c1', modelCallId: 'm1', toolType: 'shell_tool_call', name: 'bash', arguments: '' }, + }); + expect(deltaOf(evs[0]!).tool_calls?.[0]).toEqual({ + index: 0, + id: 'c1', + type: 'function', + function: { name: 'bash' }, + }); + }); + + test('partial_tool_call appends arguments to the current index', () => { + const t = mk('gpt-4o'); + t.translate({ type: 'tool_call_started', toolCall: { callId: 'c1', modelCallId: 'm', toolType: 'x', name: 'search', arguments: '' } }); + const evs = t.translate({ type: 'partial_tool_call', partialArgs: '{"q":"x"' }); + expect(deltaOf(evs[0]!).tool_calls?.[0]).toEqual({ index: 0, function: { arguments: '{"q":"x"' } }); + }); + + test('exec_request mcp becomes a tool_calls delta with toolName + JSON args', () => { + const t = mk('gpt-4o'); + const execRequest: Extract = { + type: 'mcp', + id: 1, + execId: 'e1', + name: 'cursor-tools-search', + args: { q: 'x', n: 3 }, + toolCallId: 'call_1', + providerIdentifier: 'cursor-tools', + toolName: 'search', + }; + const evs = t.translate({ type: 'exec_request', execRequest }); + expect(deltaOf(evs[0]!).tool_calls?.[0]).toEqual({ + index: 0, + id: 'call_1', + type: 'function', + function: { name: 'search', arguments: JSON.stringify({ q: 'x', n: 3 }) }, + }); + }); + + test('exec_request built-in tools are not translated', () => { + const t = mk('gpt-4o'); + const evs = t.translate({ type: 'exec_request', execRequest: { type: 'shell', id: 1, command: 'ls', cwd: '/' } }); + expect(evs).toEqual([]); + }); +}); + +describe('createAgentTranslator.finalize', () => { + test('emits stop then a zero-usage frame when no token signal arrived', () => { + const t = mk('gpt-4o'); + t.translate({ type: 'text', content: 'hi' }); + const fin = t.finalize(); + expect(fin).toHaveLength(2); + expect(finishOf(fin[0]!)).toBe('stop'); + expect(deltaOf(fin[0]!)).toEqual({}); + // The trailing usage frame carries empty choices + an all-zero usage block + // when cursor sent no tokenDelta/checkpoint (only gets the request counted). + expect(fin[1]!.choices).toEqual([]); + expect(fin[1]!.usage).toEqual({ prompt_tokens: 0, completion_tokens: 0, total_tokens: 0 }); + }); + + test('sums tokenDelta into completion and reads usedTokens as the total', () => { + const t = mk('gpt-4o'); + t.translate({ type: 'text', content: 'hi' }); + t.translate({ type: 'token', tokens: 1 }); + t.translate({ type: 'token', tokens: 1 }); + t.translate({ type: 'token', tokens: 4 }); + t.translate({ type: 'checkpoint', usedTokens: 21132, maxTokens: 262000 }); + const fin = t.finalize(); + // completion = Σ tokenDelta = 6; total = usedTokens = 21132; prompt = 21126. + expect(fin[1]!.usage).toEqual({ prompt_tokens: 21126, completion_tokens: 6, total_tokens: 21132 }); + }); + + test('falls back to all-zero when no checkpoint arrived (turn not accountable)', () => { + const t = mk('gpt-4o'); + t.translate({ type: 'token', tokens: 3 }); + t.translate({ type: 'token', tokens: 2 }); + const fin = t.finalize(); + // No ConversationTokenDetails checkpoint → not accountable → all-zero + // (the request is still counted via the non-null usage object). + expect(fin[1]!.usage).toEqual({ prompt_tokens: 0, completion_tokens: 0, total_tokens: 0 }); + }); + + test('keeps the last checkpoint usedTokens when several arrive', () => { + const t = mk('gpt-4o'); + t.translate({ type: 'checkpoint', usedTokens: 100, maxTokens: 262000 }); + t.translate({ type: 'token', tokens: 10 }); + t.translate({ type: 'checkpoint', usedTokens: 21132, maxTokens: 262000 }); + const fin = t.finalize(); + expect(fin[1]!.usage).toEqual({ prompt_tokens: 21122, completion_tokens: 10, total_tokens: 21132 }); + }); + + test('tool-call turn reports all-zero usage even with token/checkpoint signal', () => { + // Intermediate tool-call turns pause before the run's final checkpoint; the + // real usage lands on the final turn. finish_reason tool_calls → all-zero. + const t = mk('gpt-4o'); + t.translate({ + type: 'exec_request', + execRequest: { type: 'mcp', id: 1, name: 'x', args: {}, toolCallId: 'c1', providerIdentifier: 'p', toolName: 'x' }, + }); + t.translate({ type: 'token', tokens: 12 }); + t.translate({ type: 'checkpoint', usedTokens: 9000, maxTokens: 262000 }); + const fin = t.finalize(); + expect(finishOf(fin[0]!)).toBe('tool_calls'); + expect(fin[1]!.usage).toEqual({ prompt_tokens: 0, completion_tokens: 0, total_tokens: 0 }); + }); + + test('observedMaxTokens surfaces the checkpoint context window (null until seen)', () => { + const t = mk('gpt-4o'); + expect(t.observedMaxTokens()).toBeNull(); + t.translate({ type: 'checkpoint', usedTokens: 9000, maxTokens: 262000 }); + expect(t.observedMaxTokens()).toBe(262000); + // A later checkpoint without maxTokens does not clear the observation. + t.translate({ type: 'checkpoint', usedTokens: 9100 }); + expect(t.observedMaxTokens()).toBe(262000); + }); + + test('emits tool_calls finish_reason after an mcp exec', () => { + const t = mk('gpt-4o'); + t.translate({ + type: 'exec_request', + execRequest: { type: 'mcp', id: 1, name: 'x', args: {}, toolCallId: 'c1', providerIdentifier: 'p', toolName: 'x' }, + }); + expect(finishOf(t.finalize()[0]!)).toBe('tool_calls'); + }); + + test('is idempotent', () => { + const t = mk('gpt-4o'); + t.finalize(); + expect(t.finalize()).toEqual([]); + }); +}); + +describe('createAgentTranslator — passthrough chunks', () => { + test('checkpoint/heartbeat/interaction_query/exec_server_abort yield nothing', () => { + const t = mk('gpt-4o'); + const chunks: AgentStreamChunk[] = [ + { type: 'checkpoint' }, + { type: 'heartbeat' }, + { type: 'interaction_query', queryId: 1, queryType: 'web_search' }, + { type: 'exec_server_abort' }, + ]; + for (const c of chunks) expect(t.translate(c)).toEqual([]); + }); +}); diff --git a/packages/provider-cursor/src/agent-transport.ts b/packages/provider-cursor/src/agent-transport.ts new file mode 100644 index 000000000..9c35f799f --- /dev/null +++ b/packages/provider-cursor/src/agent-transport.ts @@ -0,0 +1,709 @@ +/** + * Cursor Agent transport — HTTP/1.1 dual-channel driver. + * + * RunSSE (server-streaming read) + BidiAppend (unary write) over plain fetch, + * no node:http2. Workers + Node dual-target. Produces AgentStreamChunk events + * consumed by agent-translate; the caller bridges those into Floway's + * ProviderStreamResult. + * + * Workers-clean: crypto.randomUUID, bytesToHex, Uint8Array fetch bodies, + * DecompressionStream for gzip. Environment facts come via RequestContextEnv. + */ + +import { + CURSOR_BIDI_APPEND_PATH, + CURSOR_GRPC_WEB_CONTENT_TYPE, + CURSOR_RUN_SSE_PATH, + CURSOR_USER_AGENT, +} from './constants.ts'; +import { TEXT_DECODER } from './proto/decoding.ts'; +import { + bytesToHex, + addConnectEnvelope, + readConnectFrame, + isTrailerFrame, + isCompressedFrame, + parseTrailerMetadata, + decompressGzip, + parseProtoFields, + parseInteractionUpdate, + parseCheckpointTokenDetails, + parseExecServerMessage, + parseKvServerMessage, + analyzeBlobData, + extractAssistantContent, + buildKvClientMessage, + buildAgentClientMessageWithKv, + buildExecClientMessageWithMcpResult, + buildExecClientMessageWithRequestContextResult, + buildExecClientMessageWithRejectedTool, + buildAgentClientMessageWithExec, + buildExecClientControlMessage, + buildAgentClientMessageWithExecControl, + encodeBidiRequestId, + encodeBidiAppendRequest, + buildRequestContext, + encodeUserMessage, + encodeUserMessageAction, + encodeConversationAction, + encodeModelDetails, + encodeAgentRunRequest, + encodeAgentClientMessage, + AgentMode, + type AgentStreamChunk, + type AgentChatRequest, + type ExecRequest, + type KvServerMessage, + type McpResult, + type RequestContextEnv, +} from './proto/index.ts'; + +const MAX_BLOB_STORE_SIZE = 64; + +// Repackage a parsed tool-call started/completed record as an AgentStreamChunk. +// Both events use the same payload shape and differ only by the chunk `type`. +type ToolCallLike = { + callId: string; + modelCallId: string; + toolType: string; + name: string; + arguments: string; +}; +const toolCallChunk = ( + type: 'tool_call_started' | 'tool_call_completed', + tc: ToolCallLike, +): AgentStreamChunk => ({ + type, + toolCall: { + callId: tc.callId, + modelCallId: tc.modelCallId, + toolType: tc.toolType, + name: tc.name, + arguments: tc.arguments, + }, +}); + +const DEFAULT_HEARTBEAT = { + // No progress yet: be conservative — close fast if the backend stalls before + // first output so we don't burn a Workers subrequest on a dead turn. + idleBeforeProgressMs: 30_000, + maxBeforeProgress: 15, + // After first output: the turn ends authoritatively on the IU[14] turn_ended + // frame (or an exec pause). This idle window is only a stall safety net for a + // genuinely wedged upstream that stops sending turn_ended AND stops streaming + // — kept generous so a long mid-answer reasoning pause (which emits + // heartbeats) is never mistaken for the end. + idleAfterProgressMs: 30_000, +}; + +export interface AgentTransportOptions { + /** Supplies the Bearer access token. A getter (not a captured string) so a + * resume on a long-lived session can swap in a freshly-minted token. */ + getAuthToken: () => string; + baseUrl: string; + env: RequestContextEnv; + clientVersion: string; + /** Privacy/ghost-mode toggle sent as x-ghost-mode. */ + privacyMode?: boolean; + /** Supplies the x-cursor-checksum header (pure-compute, see checksum.ts). */ + getChecksum: () => string; + /** Optional fetch injection (tests / per-upstream proxy). Defaults to global. */ + fetch?: typeof fetch; + /** BidiAppend retry count for transient network errors. Default 1. */ + maxRetries?: number; + heartbeat?: Partial; +} + +const RETRYABLE_NETWORK_HINTS = [ + 'socket', + 'connection', + 'econnreset', + 'etimedout', + 'epipe', + 'network', + 'fetch', +]; + +function isRetryableNetworkError(message: string): boolean { + const lower = message.toLowerCase(); + return RETRYABLE_NETWORK_HINTS.some(hint => lower.includes(hint)); +} + +/** + * Cursor Agent dual-channel transport. One instance per chat turn — the blob + * store and seqno are turn-scoped and cleared on openChatStream. + */ +export class AgentTransport { + private readonly getAuthToken: () => string; + private readonly baseUrl: string; + private readonly env: RequestContextEnv; + private readonly clientVersion: string; + private readonly privacyMode: boolean; + private readonly getChecksum: () => string; + private readonly fetchFn: typeof fetch; + private readonly maxRetries: number; + private readonly heartbeat: typeof DEFAULT_HEARTBEAT; + + private readonly blobStore = new Map(); + private readonly blobStoreOrder: string[] = []; + + // Turn-scoped state. seed() sets requestId+seqno before open/resume; the read + // stream + socket lifetime are owned by the DurableHttpSession, so the + // transport no longer holds an AbortController/timeout of its own. + private currentRequestId: string | null = null; + private currentAppendSeqno = 0n; + // RunSSE bytes read past the exec_mcp frame but not yet parsed, captured at + // the pause so a cross-instance resume can prepend them (see driveReadLoop). + private suspendedLeftover: Uint8Array | null = null; + + constructor(opts: AgentTransportOptions) { + this.getAuthToken = opts.getAuthToken; + this.baseUrl = opts.baseUrl.replace(/\/$/, ''); + this.env = opts.env; + this.clientVersion = opts.clientVersion; + this.privacyMode = opts.privacyMode ?? true; + this.getChecksum = opts.getChecksum; + this.fetchFn = opts.fetch ?? globalThis.fetch; + this.maxRetries = opts.maxRetries ?? 1; + this.heartbeat = { ...DEFAULT_HEARTBEAT, ...opts.heartbeat }; + } + + /** The unparsed RunSSE remainder at the last exec_mcp pause (usually empty). */ + get leftover(): Uint8Array | null { + return this.suspendedLeftover; + } + + /** The next BidiAppend seqno (to persist for a cross-instance resume). */ + get seqno(): bigint { + return this.currentAppendSeqno; + } + + private getHeaders(requestId?: string): Record { + const headers: Record = { + authorization: `Bearer ${this.getAuthToken()}`, + 'content-type': CURSOR_GRPC_WEB_CONTENT_TYPE, + 'user-agent': CURSOR_USER_AGENT, + 'x-cursor-checksum': this.getChecksum(), + 'x-cursor-client-version': this.clientVersion, + 'x-cursor-client-type': 'cli', + 'x-cursor-timezone': this.env.timezone, + 'x-ghost-mode': this.privacyMode ? 'true' : 'false', + // Ask the backend to stream text back over RunSSE instead of stashing + // assistant responses in KV blobs. + 'x-cursor-streaming': 'true', + }; + if (requestId) headers['x-request-id'] = requestId; + return headers; + } + + private blobIdToKey(blobId: Uint8Array): string { + return bytesToHex(blobId); + } + + private storeBlob(key: string, data: Uint8Array): void { + const existingIndex = this.blobStoreOrder.indexOf(key); + if (existingIndex !== -1) this.blobStoreOrder.splice(existingIndex, 1); + + while (this.blobStore.size >= MAX_BLOB_STORE_SIZE && this.blobStoreOrder.length > 0) { + const oldestKey = this.blobStoreOrder.shift(); + if (oldestKey) this.blobStore.delete(oldestKey); + } + + this.blobStore.set(key, data); + this.blobStoreOrder.push(key); + } + + private clearBlobStore(): void { + this.blobStore.clear(); + this.blobStoreOrder.length = 0; + } + + /** + * POST one AgentClientMessage on the BidiAppend write channel. Retries + * transient network errors up to maxRetries with exponential backoff. + */ + async bidiAppend(requestId: string, appendSeqno: bigint, data: Uint8Array): Promise { + const hexData = bytesToHex(data); + const appendRequest = encodeBidiAppendRequest(hexData, requestId, appendSeqno); + const envelope = addConnectEnvelope(appendRequest); + const url = `${this.baseUrl}${CURSOR_BIDI_APPEND_PATH}`; + + let lastError: Error | null = null; + for (let attempt = 0; attempt <= this.maxRetries; attempt++) { + try { + const response = await this.fetchFn(url, { + method: 'POST', + headers: this.getHeaders(requestId), + body: envelope as BodyInit, + }); + + if (!response.ok) { + const errorText = await response.text(); + throw new Error(`BidiAppend failed: ${response.status} - ${errorText}`); + } + + // Drain the (usually empty) response body. + await response.arrayBuffer(); + return; + } catch (err) { + lastError = err instanceof Error ? err : new Error(String(err)); + if (isRetryableNetworkError(lastError.message) && attempt < this.maxRetries) { + const delay = Math.min(1000 * 2 ** attempt, 4000); + await new Promise(resolve => setTimeout(resolve, delay)); + continue; + } + throw lastError; + } + } + throw lastError ?? new Error('BidiAppend failed with unknown error'); + } + + private buildChatMessage(request: AgentChatRequest): Uint8Array { + const messageId = crypto.randomUUID(); + const conversationId = request.conversationId ?? crypto.randomUUID(); + const model = request.model ?? 'gpt-4o'; + const mode = request.mode ?? AgentMode.AGENT; + + const requestContext = buildRequestContext(this.env, request.tools); + const userMessage = encodeUserMessage(request.message, messageId, mode, request.images); + const userMessageAction = encodeUserMessageAction(userMessage, requestContext); + const conversationAction = encodeConversationAction(userMessageAction); + const modelDetails = encodeModelDetails(model, request.maxMode); + const agentRunRequest = encodeAgentRunRequest( + conversationAction, + modelDetails, + conversationId, + request.tools, + this.env.workspacePath, + ); + return encodeAgentClientMessage(agentRunRequest); + } + + private async handleKvMessage(kvMsg: KvServerMessage, requestId: string): Promise { + const seqno = this.currentAppendSeqno; + + if (kvMsg.messageType === 'get_blob_args' && kvMsg.blobId) { + const key = this.blobIdToKey(kvMsg.blobId); + const data = this.blobStore.get(key); + const result = data ?? new Uint8Array(0); + const kvClientMsg = buildKvClientMessage(kvMsg.id, 'get_blob_result', result); + const responseMsg = buildAgentClientMessageWithKv(kvClientMsg); + await this.bidiAppend(requestId, seqno, responseMsg); + return seqno + 1n; + } + + if (kvMsg.messageType === 'set_blob_args' && kvMsg.blobId && kvMsg.blobData) { + const key = this.blobIdToKey(kvMsg.blobId); + this.storeBlob(key, kvMsg.blobData); + + const result = new Uint8Array(0); + const kvClientMsg = buildKvClientMessage(kvMsg.id, 'set_blob_result', result); + const responseMsg = buildAgentClientMessageWithKv(kvClientMsg); + await this.bidiAppend(requestId, seqno, responseMsg); + return seqno + 1n; + } + + return seqno; + } + + /** + * Send an ExecClientMessage result then the stream-close control frame. + * Used by every tool-result path (mcp, rejected built-in, request_context). + */ + private async sendExecAndClose(id: number, execId: string | undefined, execClientMessage: Uint8Array): Promise { + if (!this.currentRequestId) throw new Error('No active chat stream — cannot send exec result'); + + const responseMsg = buildAgentClientMessageWithExec(execClientMessage); + await this.bidiAppend(this.currentRequestId, this.currentAppendSeqno, responseMsg); + this.currentAppendSeqno += 1n; + + const controlMsg = buildExecClientControlMessage(id); + const controlResponseMsg = buildAgentClientMessageWithExecControl(controlMsg); + await this.bidiAppend(this.currentRequestId, this.currentAppendSeqno, controlResponseMsg); + this.currentAppendSeqno += 1n; + } + + /** + * Send an ExecClientMessage result WITHOUT a stream-close control frame. + * Cursor's MCP protocol treats a single mcp_result as the complete tool + * output and auto-resumes the model turn on the same RunSSE read channel — + * an extra exec-stream-close (or a ResumeAction) makes the server finalize + * the turn instead of continuing, yielding an empty follow-up reply. This + * mirrors the validated HTTP/2 reference (OmniRoute cursorSessionManager + * .sendToolResult → encodeExecMcpResult, single frame, no close/resume). + */ + private async sendExecResultNoClose(execClientMessage: Uint8Array): Promise { + if (!this.currentRequestId) throw new Error('No active chat stream — cannot send exec result'); + const responseMsg = buildAgentClientMessageWithExec(execClientMessage); + await this.bidiAppend(this.currentRequestId, this.currentAppendSeqno, responseMsg); + this.currentAppendSeqno += 1n; + } + + /** + * Send an MCP tool result from just the exec identity (id + exec_id) — a + * cross-instance resume reconstructs these from the client's echoed + * tool_call_id (see session-id.ts decodeToolCallId), without the original + * exec_request object. + */ + async sendMcpResultRaw(id: number, execId: string | undefined, result: McpResult): Promise { + const execClientMsg = buildExecClientMessageWithMcpResult(id, execId, result); + await this.sendExecResultNoClose(execClientMsg); + } + + /** Reject a built-in tool request so the model can adapt and keep streaming. */ + async sendRejectedTool(execRequest: ExecRequest, reason: string): Promise { + const execClientMsg = buildExecClientMessageWithRejectedTool(execRequest, reason); + await this.sendExecAndClose(execRequest.id, execRequest.execId, execClientMsg); + } + + /** Answer a request_context exec with the gateway environment. */ + async sendRequestContextResult(id: number, execId: string | undefined): Promise { + const execClientMsg = buildExecClientMessageWithRequestContextResult(id, execId, this.env); + await this.sendExecAndClose(id, execId, execClientMsg); + } + + /** + * Seed the turn-scoped write-channel state before open/resume. requestId + + * seqno are generated/loaded by the caller (fetch.ts) so they can be shared + * with the DurableHttpSession RunSSE request and persisted to D1 for a + * cross-instance resume. + */ + seed(requestId: string, seqno: bigint): void { + this.currentRequestId = requestId; + this.currentAppendSeqno = seqno; + this.suspendedLeftover = null; + this.clearBlobStore(); + } + + /** + * The RunSSE POST spec the caller hands to DurableHttpSession.acquire — the + * read socket lives in the session, not the transport. The body is just the + * requestId envelope; the actual RunRequest goes out on BidiAppend (open()). + */ + runSseInit(requestId: string): { method: 'POST'; url: string; headers: Record; body: Uint8Array } { + return { + method: 'POST', + url: `${this.baseUrl}${CURSOR_RUN_SSE_PATH}`, + headers: this.getHeaders(requestId), + body: addConnectEnvelope(encodeBidiRequestId(requestId)), + }; + } + + /** + * Open turn: send the initial RunRequest on BidiAppend (seqno 0, seeded by + * seed()), then pump the provided RunSSE read stream. The stream comes from + * the DurableHttpSession; the transport never fetches RunSSE itself. + */ + async *openChatStream(opts: { readStream: ReadableStream; request: AgentChatRequest }): AsyncGenerator { + const requestId = this.currentRequestId; + if (!requestId) throw new Error('openChatStream called before seed()'); + const messageBody = this.buildChatMessage(opts.request); + try { + await this.bidiAppend(requestId, this.currentAppendSeqno, messageBody); + this.currentAppendSeqno += 1n; + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + yield { type: 'error', error: `BidiAppend initial RunRequest failed: ${message}` }; + return; + } + yield* this.driveReadLoop(opts.readStream.getReader(), new Uint8Array(0)); + } + + /** + * Resume turn: the RunRequest was already sent on a prior turn and the + * caller has seeded {requestId, seqno} and sent the ExecMcpResult; we just + * pump the continued RunSSE read stream, prepending any leftover bytes the + * prior turn read past its exec_mcp pause. + */ + async *resumeChatStream(opts: { readStream: ReadableStream; leftover: Uint8Array | null }): AsyncGenerator { + yield* this.driveReadLoop(opts.readStream.getReader(), opts.leftover ?? new Uint8Array(0)); + } + + /** + * Pump AgentServerMessage frames off the RunSSE read stream as + * AgentStreamChunk events until turn_ended / idle / done / error. + * + * The caller drives exec_request disposition: on an mcp exec it translates to + * a downstream tool_calls chunk and stops pulling (the pause point — leftover + * is captured here); on a built-in/request_context exec it answers on the + * write channel and keeps pulling. + */ + private async *driveReadLoop(reader: ReadableStreamDefaultReader, initialBuffer: Uint8Array): AsyncGenerator { + let lastProgressAt = Date.now(); + let heartbeatSinceProgress = 0; + let hasProgress = false; + const markProgress = (): void => { + heartbeatSinceProgress = 0; + lastProgressAt = Date.now(); + hasProgress = true; + }; + + const pendingAssistantBlobs: Array<{ blobId: string; content: string }> = []; + let hasStreamedText = false; + let streamedTextChars = 0; + + try { + let buffer = initialBuffer; + // Read cursor into `buffer` — advanced past every complete frame we've + // parsed. The next read merges only the still-unparsed tail (buffer from + // `offset` onward) with the new chunk instead of re-concatenating the + // whole running buffer on every reader.read(). + let offset = 0; + let turnEnded = false; + + try { + while (!turnEnded) { + const { done, value } = await reader.read(); + if (done) { + yield { type: 'done' }; + break; + } + if (!value || value.byteLength === 0) continue; + + const tailLen = buffer.length - offset; + if (tailLen === 0) { + // Fast path: buffer was fully consumed, no copy needed. + buffer = value; + } else { + const merged = new Uint8Array(tailLen + value.length); + merged.set(buffer.subarray(offset), 0); + merged.set(value, tailLen); + buffer = merged; + } + offset = 0; + // Parse as many complete connect frames as the buffer currently holds. + + while (true) { + const frame = readConnectFrame(buffer, offset); + if (!frame) break; + offset = frame.nextOffset; + + let payload = frame.payload; + if (isCompressedFrame(frame.flags)) { + try { + payload = await decompressGzip(frame.payload); + } catch { + // leave payload compressed; field parsing will likely skip it + } + } + + if (isTrailerFrame(frame.flags)) { + const trailer = TEXT_DECODER.decode(payload); + const meta = parseTrailerMetadata(trailer); + const grpcStatus = Number(meta['grpc-status'] ?? '0'); + if (grpcStatus !== 0) { + const grpcMessage = meta['grpc-message'] ? decodeURIComponent(meta['grpc-message']) : 'Unknown gRPC error'; + yield { type: 'error', error: `${grpcMessage} (grpc-status ${grpcStatus})` }; + } + continue; + } + + const serverMsgFields = parseProtoFields(payload); + for (const field of serverMsgFields) { + try { + if (field.fieldNumber === 1 && field.wireType === 2 && field.value instanceof Uint8Array) { + const parsed = parseInteractionUpdate(field.value); + + if (parsed.text) { + hasStreamedText = true; + streamedTextChars += parsed.text.length; + markProgress(); + yield { type: 'text', content: parsed.text }; + } + if (parsed.thinking) { + markProgress(); + yield { type: 'thinking', content: parsed.thinking }; + } + if (parsed.toolCallStarted) { + markProgress(); + yield toolCallChunk('tool_call_started', parsed.toolCallStarted); + } + if (parsed.toolCallCompleted) { + markProgress(); + yield toolCallChunk('tool_call_completed', parsed.toolCallCompleted); + } + if (parsed.partialToolCall) { + markProgress(); + yield { + type: 'partial_tool_call', + toolCall: { + callId: parsed.partialToolCall.callId, + toolType: 'partial', + name: 'partial', + arguments: '', + }, + partialArgs: parsed.partialToolCall.argsTextDelta, + }; + } + if (parsed.tokenDelta !== null) { + // Cursor's streamed output-token counter. Real activity, so + // it counts as progress; the translator sums it into the + // per-request completion_tokens. + markProgress(); + yield { type: 'token', tokens: parsed.tokenDelta }; + } + if (parsed.isComplete) { + // InteractionUpdate field 14 (turn_ended) — cursor's + // authoritative end-of-turn marker. Verified on the wire to + // arrive after the final answer text (after the closing KV + // checkpoints and the field-17 usage update). This is the + // signal we close the session on; everything below is a + // stall-safety fallback, not a normal end path. + turnEnded = true; + } + if (parsed.isHeartbeat) { + heartbeatSinceProgress++; + const idleMs = Date.now() - lastProgressAt; + if (hasProgress) { + // The model has already produced output. Heartbeats here + // are keep-alives during cursor's own pauses (KV + // checkpointing, the gap before turn_ended, mid-answer + // reasoning). They must NOT preempt the turn_ended frame — + // a raw beat count would truncate short answers whose + // turn_ended lags a beat or two behind the last text + // (observed). So after progress we only close on a long + // *time* idle (a genuine upstream stall), never on count. + if (idleMs >= this.heartbeat.idleAfterProgressMs) { + turnEnded = true; + } else { + yield { type: 'heartbeat' }; + } + } else if (heartbeatSinceProgress >= this.heartbeat.maxBeforeProgress || idleMs >= this.heartbeat.idleBeforeProgressMs) { + // Before any output: a stream that never produces is + // detected by either a beat count or an idle window. + turnEnded = true; + } else { + yield { type: 'heartbeat' }; + } + } + } else if (field.fieldNumber === 3 && field.wireType === 2 && field.value instanceof Uint8Array) { + // conversation_checkpoint_update — NOT a completion signal; + // exec_server_message can follow. Just mark progress. Cursor + // stamps the live context accounting (used/max tokens) here; + // surface it so the translator can report a per-request total. + markProgress(); + const td = parseCheckpointTokenDetails(field.value); + if (td) yield { type: 'checkpoint', usedTokens: td.usedTokens, maxTokens: td.maxTokens }; + else yield { type: 'checkpoint' }; + } else if (field.fieldNumber === 2 && field.wireType === 2 && field.value instanceof Uint8Array) { + const execRequest = parseExecServerMessage(field.value); + if (execRequest) { + markProgress(); + // An mcp exec is the pause point: the caller stops pulling + // here. Capture the unparsed remainder so a cross-instance + // resume (fresh transport, fresh DurableHttpSession view) can + // prepend it — these bytes were already dequeued from the + // session and won't be re-served. Usually empty (cursor + // pauses right after exec_mcp). + if (execRequest.type === 'mcp') this.suspendedLeftover = buffer.slice(offset); + yield { type: 'exec_request', execRequest }; + } + } else if (field.fieldNumber === 4 && field.wireType === 2 && field.value instanceof Uint8Array) { + const kvMsg = parseKvServerMessage(field.value); + if (kvMsg.messageType === 'set_blob_args' && kvMsg.blobId && kvMsg.blobData) { + const key = this.blobIdToKey(kvMsg.blobId); + const analysis = analyzeBlobData(kvMsg.blobData); + for (const item of extractAssistantContent(analysis, key)) { + pendingAssistantBlobs.push(item); + } + } + // kv_server_message: cursor checkpointing conversation state + // (it pushes set_blob frames for us to store, and get_blob + // frames to re-read on a follow-up). NOT a turn-end signal — + // turn termination is the authoritative IU[14] turn_ended + // frame (or an exec pause), which always arrives after the + // closing KV checkpoints. We answer KV here (blob store) so + // the model can finish; a stalled reply pins the seqno and + // wedges the whole turn (see handleKvMessage). + try { + // Assign the returned next-seqno back: handleKvMessage sends + // a kv_client_message on the shared BidiAppend channel and + // returns seqno+1. Dropping the return value pinned the + // seqno, so multiple blob requests in one turn (common on a + // tool-result follow-up, where cursor re-fetches conversation + // blobs) all replied at the same seqno — cursor ignores the + // duplicates, the blob channel stalls, and the model emits an + // empty continuation. + this.currentAppendSeqno = await this.handleKvMessage(kvMsg, this.currentRequestId!); + } catch (kvErr) { + const msg = kvErr instanceof Error ? kvErr.message : String(kvErr); + if (isRetryableNetworkError(msg)) { + yield { type: 'error', error: `Network error during KV: ${msg}` }; + turnEnded = true; + } + // non-network KV errors: swallow, keep streaming + } + } else if (field.fieldNumber === 5 && field.wireType === 2 && field.value instanceof Uint8Array) { + markProgress(); + yield { type: 'exec_server_abort' }; + } else if (field.fieldNumber === 7 && field.wireType === 2 && field.value instanceof Uint8Array) { + const queryFields = parseProtoFields(field.value); + let queryId = 0; + let queryType = 'unknown'; + for (const qf of queryFields) { + if (qf.fieldNumber === 1 && qf.wireType === 0) queryId = Number(qf.value); + else if (qf.fieldNumber === 2) queryType = 'web_search'; + else if (qf.fieldNumber === 3) queryType = 'ask_question'; + else if (qf.fieldNumber === 4) queryType = 'switch_mode'; + else if (qf.fieldNumber === 5) queryType = 'exa_search'; + else if (qf.fieldNumber === 6) queryType = 'exa_fetch'; + } + markProgress(); + yield { type: 'interaction_query', queryId, queryType }; + } + } catch (parseErr) { + const msg = parseErr instanceof Error ? parseErr.message : String(parseErr); + if (isRetryableNetworkError(msg)) { + yield { type: 'error', error: `Network error: ${msg}` }; + turnEnded = true; + } else { + yield { type: 'error', error: `Parse error in field ${field.fieldNumber}: ${msg}` }; + } + } + } + + if (turnEnded) break; + } + } + + if (turnEnded) { + if (pendingAssistantBlobs.length > 0) { + if (!hasStreamedText) { + // Recovery path: the model streamed no text this turn (an + // observed empty continuation), but cursor checkpointed the + // answer into KV blobs — surface them so the caller isn't left + // with nothing. + for (const blob of pendingAssistantBlobs) { + yield { type: 'kv_blob_assistant', blobContent: blob.content }; + } + } else { + // Discard boundary: the streamed text is the answer and the KV + // blob is a redundant checkpoint mirror, so it's dropped to avoid + // duplicating the reply. Measured over batches of long kimi-k2.5 + // conversations, this fires on essentially every turn and the blob + // is a byte-for-byte mirror of the stream (zero content lost). So + // we stay silent on the normal case and only warn if a blob ever + // carries MORE than the stream did — the one shape where the drop + // would lose real content (e.g. a stream truncated mid-answer). + const blobChars = pendingAssistantBlobs.reduce((n, b) => n + b.content.length, 0); + if (blobChars > streamedTextChars) { + console.warn( + `Cursor KV-blob exceeds stream: req=${this.currentRequestId ?? '?'} ` + + `blobChars=${blobChars} streamedChars=${streamedTextChars} ` + + `blobs=${pendingAssistantBlobs.length} — discarded blob carried content beyond the stream`, + ); + } + } + } + yield { type: 'done' }; + } + } finally { + reader.releaseLock(); + } + } catch (err: unknown) { + const error = err as Error & { name?: string }; + if (error.name === 'AbortError') return; // stream cancelled by the caller + yield { type: 'error', error: error.message || String(err) }; + } + } +} diff --git a/packages/provider-cursor/src/agent-transport_test.ts b/packages/provider-cursor/src/agent-transport_test.ts new file mode 100644 index 000000000..b9551e10a --- /dev/null +++ b/packages/provider-cursor/src/agent-transport_test.ts @@ -0,0 +1,200 @@ +import { describe, expect, test, vi } from 'vitest'; + +import { AgentTransport } from './agent-transport.ts'; +import { addConnectEnvelope, encodeMessageField, encodeStringField } from './proto/index.ts'; +import type { AgentChatRequest, RequestContextEnv } from './proto/index.ts'; + +const ENV: RequestContextEnv = { workspacePath: '/tmp', osVersion: 'darwin 24.0.0', shell: '/bin/zsh', timezone: 'UTC' }; + +function makeTransport(fetchMock: unknown): AgentTransport { + return new AgentTransport({ + getAuthToken: () => 'tok', + baseUrl: 'https://api2.cursor.sh', + env: ENV, + clientVersion: 'cli-test', + getChecksum: () => 'checksum-value', + fetch: fetchMock as typeof fetch, + maxRetries: 1, + }); +} + +// The transport no longer fetches RunSSE itself (the DurableHttpSession owns +// the read socket); it drives a provided read stream. Build one from frames. +function readStreamOf(...frames: Uint8Array[]): ReadableStream { + return new ReadableStream({ + start(controller) { + for (const f of frames) controller.enqueue(f); + controller.close(); + }, + }); +} + +// Seed + open a turn over a frame stream. fetchMock only serves the BidiAppend +// write channel now. +function openOver(transport: AgentTransport, ...frames: Uint8Array[]): AsyncGenerator { + transport.seed('req-test', 0n); + return transport.openChatStream({ readStream: readStreamOf(...frames), request: { message: 'hi', model: 'gpt-4o' } as AgentChatRequest }); +} + +function okEmptyResponse(): Response { + return new Response(new Uint8Array(0), { status: 200 }); +} + +// AgentServerMessage { field 1: InteractionUpdate { field 1: TextDeltaUpdate { field 1: text } } } +function textFrame(text: string): Uint8Array { + const interactionUpdate = encodeMessageField(1, encodeStringField(1, text)); + const serverMsg = encodeMessageField(1, interactionUpdate); + return addConnectEnvelope(serverMsg); +} + +function turnEndedFrame(): Uint8Array { + // InteractionUpdate.field 14 (turn_ended), wire type 0, value 0 — presence matters + const interactionUpdate = new Uint8Array([(14 << 3) | 0, 0]); + const serverMsg = encodeMessageField(1, interactionUpdate); + return addConnectEnvelope(serverMsg); +} + +function heartbeatFrame(): Uint8Array { + // InteractionUpdate.field 13 (heartbeat), wire type 0, value 0 + const interactionUpdate = new Uint8Array([(13 << 3) | 0, 0]); + const serverMsg = encodeMessageField(1, interactionUpdate); + return addConnectEnvelope(serverMsg); +} + +async function collect(gen: AsyncGenerator): Promise { + const out: unknown[] = []; + for await (const chunk of gen) out.push(chunk); + return out; +} + +const BIDI_URL = 'https://api2.cursor.sh/aiserver.v1.BidiService/BidiAppend'; + +describe('AgentTransport.bidiAppend', () => { + test('POSTs the envelope on the write channel', async () => { + const fetchMock = vi.fn(async () => okEmptyResponse()); + const transport = makeTransport(fetchMock); + await transport.bidiAppend('req-1', 0n, new Uint8Array([1, 2, 3])); + + expect(fetchMock).toHaveBeenCalledTimes(1); + const [url, init] = fetchMock.mock.calls[0] as unknown as [unknown, RequestInit]; + expect(url).toBe(BIDI_URL); + expect(init.method).toBe('POST'); + const headers = init.headers as Record; + expect(headers['authorization']).toBe('Bearer tok'); + expect(headers['x-cursor-checksum']).toBe('checksum-value'); + expect(headers['content-type']).toBe('application/grpc-web+proto'); + }); + + test('retries on transient network errors', async () => { + const fetchMock = vi.fn(async () => { + if (fetchMock.mock.calls.length === 1) throw new Error('ECONNRESET socket closed'); + return okEmptyResponse(); + }); + const transport = makeTransport(fetchMock); + await transport.bidiAppend('req-1', 0n, new Uint8Array([1])); + expect(fetchMock).toHaveBeenCalledTimes(2); + }); + + test('does not retry on non-network errors', async () => { + const fetchMock = vi.fn(async () => { + if (fetchMock.mock.calls.length === 1) throw new Error('invalid argument'); + return okEmptyResponse(); + }); + const transport = makeTransport(fetchMock); + await expect(transport.bidiAppend('req-1', 0n, new Uint8Array([1]))).rejects.toThrow('invalid argument'); + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + + test('surfaces non-ok status as an error', async () => { + const fetchMock = vi.fn(async () => new Response('nope', { status: 401 })); + const transport = makeTransport(fetchMock); + await expect(transport.bidiAppend('req-1', 0n, new Uint8Array([1]))).rejects.toThrow('BidiAppend failed: 401'); + }); +}); + +describe('AgentTransport.openChatStream', () => { + const bidiOnlyMock = (): ReturnType => vi.fn(async (_url: string) => okEmptyResponse()); + + test('yields text then done on a clean turn', async () => { + const transport = makeTransport(bidiOnlyMock()); + const chunks = await collect(openOver(transport, textFrame('hello'), turnEndedFrame())); + + const types = chunks.map(c => (c as { type: string }).type); + expect(types).toContain('text'); + expect((chunks.find(c => (c as { type: string }).type === 'text') as { content?: string })?.content).toBe('hello'); + expect(types[types.length - 1]).toBe('done'); + }); + + test('heartbeats after text do not preempt turn_ended (authoritative end)', async () => { + // Regression: a raw heartbeat count must NOT end the turn after output has + // started — cursor interleaves keep-alive heartbeats and KV checkpoints + // between the final text and the turn_ended (IU field 14) marker. Closing + // on a beat count truncated short answers whose turn_ended lagged behind. + const transport = makeTransport(bidiOnlyMock()); + const chunks = await collect(openOver( + transport, + textFrame('the answer is 42'), + heartbeatFrame(), heartbeatFrame(), heartbeatFrame(), + heartbeatFrame(), heartbeatFrame(), heartbeatFrame(), + turnEndedFrame(), + )); + const types = chunks.map(c => (c as { type: string }).type); + + // The full text survives, and the turn ends on the turn_ended frame (done + // is the last chunk), not cut short by the six intervening heartbeats. + expect((chunks.find(c => (c as { type: string }).type === 'text') as { content?: string })?.content).toBe('the answer is 42'); + expect(types[types.length - 1]).toBe('done'); + }); + + test('sends the initial RunRequest on the BidiAppend write channel', async () => { + const fetchMock = bidiOnlyMock(); + const transport = makeTransport(fetchMock); + await collect(openOver(transport, textFrame('hi'), turnEndedFrame())); + + const bidiCalls = fetchMock.mock.calls.filter(c => String(c[0]).includes('BidiAppend')); + expect(bidiCalls.length).toBeGreaterThanOrEqual(1); + }); +}); + +describe('AgentTransport.sendRejectedTool', () => { + test('sends a result frame then a stream-close control (2 BidiAppends)', async () => { + // Open a stream first so currentRequestId is set (sendExecAndClose guards it). + const bidiFetch = vi.fn(async (_url: string) => okEmptyResponse()); + const transport2 = makeTransport(bidiFetch); + // Drive the stream just enough to set currentRequestId, then reject a shell tool. + const gen = openOver(transport2, textFrame('hi'), turnEndedFrame()); + await gen.next(); // text chunk, currentRequestId now set + + const bidiBefore = bidiFetch.mock.calls.filter(c => String(c[0]).includes('BidiAppend')).length; + await transport2.sendRejectedTool({ type: 'shell', id: 9, execId: 'e1', command: 'rm', cwd: '/' }, 'no shell in gateway'); + const bidiAfter = bidiFetch.mock.calls.filter(c => String(c[0]).includes('BidiAppend')).length; + expect(bidiAfter - bidiBefore).toBe(2); // exec result + control close + await gen.return(undefined); + }); + + test('throws when no active stream is open', async () => { + const transport = makeTransport(vi.fn(async () => okEmptyResponse())); + await expect( + transport.sendRejectedTool({ type: 'shell', id: 1, command: 'x', cwd: '/' }, 'reason'), + ).rejects.toThrow('No active chat stream'); + }); +}); + +describe('AgentTransport privacy mode', () => { + test('x-ghost-mode on the RunSSE init headers reflects privacyMode (default on)', () => { + const make = (privacyMode: boolean | undefined): Record => + new AgentTransport({ + getAuthToken: () => 'tok', + baseUrl: 'https://api2.cursor.sh', + env: ENV, + clientVersion: 'cli-test', + privacyMode, + getChecksum: () => 'checksum-value', + fetch: vi.fn() as unknown as typeof fetch, + }).runSseInit('req-1').headers; + + expect(make(false)['x-ghost-mode']).toBe('false'); + expect(make(true)['x-ghost-mode']).toBe('true'); + expect(make(undefined)['x-ghost-mode']).toBe('true'); + }); +}); diff --git a/packages/provider-cursor/src/auth/import.ts b/packages/provider-cursor/src/auth/import.ts new file mode 100644 index 000000000..54d7f2abf --- /dev/null +++ b/packages/provider-cursor/src/auth/import.ts @@ -0,0 +1,67 @@ +// Cursor upstream import flow — poll-based login. +// +// Unlike Codex/Claude (callback paste), Cursor login is poll-based: the +// operator opens the authorize URL in a browser, and the gateway polls +// api2.cursor.sh/auth/poll until it returns tokens. This module owns the +// tokens → persisted config/state mapping; the authorize URL is generated +// by generateCursorAuthParams (poll.ts), and the control-plane routes wire +// pollCursorAuth in between. + +import { getTokenExpiry } from './oauth.ts'; +import type { CursorPollTokens } from './poll.ts'; +import type { CursorAccountIdentity, CursorUpstreamConfig } from '../config.ts'; +import type { CursorAccessTokenEntry, CursorUpstreamState } from '../state.ts'; + +export type { CursorPollTokens }; + +/** + * Derive the account identity from the access token JWT. Cursor's JWT payload + * fields are not fully documented; we read `sub` as the userId and `email` when + * present, falling back to a stable placeholder so the (email, userId) identity + * pair is always populated for the config tuple. + */ +export const deriveCursorIdentity = (accessToken: string): CursorAccountIdentity => { + let userId = 'cursor-user'; + let email = 'cursor-user'; + try { + const parts = accessToken.split('.'); + if (parts.length === 3 && parts[1]) { + const payload = JSON.parse(atob(parts[1].replace(/-/g, '+').replace(/_/g, '/'))) as Record; + if (typeof payload['sub'] === 'string' && payload['sub'] !== '') userId = payload['sub']; + if (typeof payload['email'] === 'string' && payload['email'] !== '') email = payload['email']; + else if (typeof payload['name'] === 'string' && payload['name'] !== '') email = payload['name']; + } + } catch { + // not a JWT — keep placeholders + } + return { email, userId }; +}; + +/** Build the persisted config (identity tuple) from a derived identity. */ +export const buildCursorImportConfig = (identity: CursorAccountIdentity): CursorUpstreamConfig => ({ + accounts: [identity], +}); + +/** + * Build the initial persisted state from poll tokens: an active credential + * with the refresh token and a cached access token entry. + */ +export const buildCursorImportState = (tokens: CursorPollTokens): CursorUpstreamState => { + const identity = deriveCursorIdentity(tokens.accessToken); + const accessTokenEntry: CursorAccessTokenEntry = { + token: tokens.accessToken, + expiresAt: getTokenExpiry(tokens.accessToken), + refreshedAt: new Date().toISOString(), + }; + return { + accounts: [ + { + userId: identity.userId, + refresh_token: tokens.refreshToken, + state: 'active', + state_updated_at: new Date().toISOString(), + accessToken: accessTokenEntry, + }, + ], + }; +}; diff --git a/packages/provider-cursor/src/auth/import_test.ts b/packages/provider-cursor/src/auth/import_test.ts new file mode 100644 index 000000000..f00393944 --- /dev/null +++ b/packages/provider-cursor/src/auth/import_test.ts @@ -0,0 +1,47 @@ +import { describe, expect, test } from 'vitest'; + +import { deriveCursorIdentity, buildCursorImportConfig, buildCursorImportState } from './import.ts'; + +const jwt = (payload: object): string => `header.${btoa(JSON.stringify(payload))}.sig`; + +describe('deriveCursorIdentity', () => { + test('reads sub + email from the JWT', () => { + const id = deriveCursorIdentity(jwt({ sub: 'u1', email: 'a@b.com' })); + expect(id.userId).toBe('u1'); + expect(id.email).toBe('a@b.com'); + }); + + test('falls back to name when email is absent', () => { + const id = deriveCursorIdentity(jwt({ sub: 'u1', name: 'Alice' })); + expect(id.email).toBe('Alice'); + }); + + test('placeholders for a non-JWT token', () => { + const id = deriveCursorIdentity('not-a-jwt'); + expect(id.userId).toBe('cursor-user'); + expect(id.email).toBe('cursor-user'); + }); +}); + +describe('buildCursorImportConfig', () => { + test('wraps an identity in a 1-tuple', () => { + const cfg = buildCursorImportConfig({ email: 'a@b', userId: 'u1' }); + expect(cfg.accounts).toHaveLength(1); + expect(cfg.accounts[0]!.userId).toBe('u1'); + }); +}); + +describe('buildCursorImportState', () => { + test('builds an active credential with a cached access token', () => { + const tokens = { accessToken: jwt({ sub: 'u1', email: 'a@b' }), refreshToken: 'ref' }; + const state = buildCursorImportState(tokens); + expect(state.accounts).toHaveLength(1); + const acc = state.accounts[0]!; + expect(acc.userId).toBe('u1'); + expect(acc.refresh_token).toBe('ref'); + expect(acc.state).toBe('active'); + expect(acc.state_updated_at).toMatch(/^\d{4}-\d{2}-\d{2}T/); + expect(acc.accessToken!.token).toBe(tokens.accessToken); + expect(acc.accessToken!.expiresAt).toBeGreaterThan(Date.now()); + }); +}); diff --git a/packages/provider-cursor/src/auth/oauth.ts b/packages/provider-cursor/src/auth/oauth.ts new file mode 100644 index 000000000..e87f6efbc --- /dev/null +++ b/packages/provider-cursor/src/auth/oauth.ts @@ -0,0 +1,98 @@ +/** + * Cursor OAuth token refresh. + * + * Cursor's refresh endpoint is `POST /auth/exchange_user_api_key` with the + * refresh token as a Bearer token and an empty JSON body — distinct from the + * standard OAuth2 /token grant_type=refresh_token flow used by Codex/Claude. + * Returns { accessToken, refreshToken }. + */ + +import type { Fetcher } from '@floway-dev/provider'; + +const CURSOR_REFRESH_URL = 'https://api2.cursor.sh/auth/exchange_user_api_key'; + +export interface CursorOAuthTokens { + access_token: string; + refresh_token: string; + /** Absolute expiry in ms epoch (exp - 5min safety margin). */ + expires_at: number; +} + +/** + * Terminal error: the refresh token is dead and the operator must re-import. + * Cursor's refresh endpoint doesn't return a structured OAuth error code, so + * we classify by HTTP status — 401/403 unambiguously mean the session is gone. + */ +export class CursorSessionTerminatedError extends Error { + readonly status: number; + readonly upstreamMessage: string; + constructor(args: { status: number; message: string }) { + super(`Cursor OAuth session terminated: ${args.message}`); + this.name = 'CursorSessionTerminatedError'; + this.status = args.status; + this.upstreamMessage = args.message; + } +} + +const isTerminalStatus = (status: number): boolean => status === 401 || status === 403; + +export const refreshCursorAccessToken = async (refreshToken: string, fetcher: Fetcher): Promise => { + const response = await fetcher(CURSOR_REFRESH_URL, { + method: 'POST', + headers: { + authorization: `Bearer ${refreshToken}`, + 'content-type': 'application/json', + }, + body: '{}', + }); + + const rawText = await response.text(); + let parsed: unknown; + try { + parsed = rawText.length > 0 ? JSON.parse(rawText) : {}; + } catch { + parsed = { _nonJsonBody: rawText }; + } + + const root = typeof parsed === 'object' && parsed !== null ? (parsed as Record) : null; + + if (!response.ok) { + const message = typeof root?.['message'] === 'string' ? (root['message'] as string) : rawText.slice(0, 256); + if (isTerminalStatus(response.status)) { + throw new CursorSessionTerminatedError({ status: response.status, message }); + } + throw new Error(`Cursor token refresh failed: ${response.status} - ${message}`); + } + + if (root === null) throw new Error('Cursor token refresh response is not an object'); + if (typeof root['accessToken'] !== 'string' || root['accessToken'] === '') { + throw new Error('Cursor token refresh response missing accessToken'); + } + + const accessToken = root['accessToken'] as string; + const refreshTokenNew = typeof root['refreshToken'] === 'string' && root['refreshToken'] !== '' ? (root['refreshToken'] as string) : refreshToken; + + return { + access_token: accessToken, + refresh_token: refreshTokenNew, + expires_at: getTokenExpiry(accessToken), + }; +}; + +/** + * Extract JWT expiry with a 5-minute safety margin. Falls back to 1 hour from + * now if the token can't be parsed as a JWT. + */ +export function getTokenExpiry(token: string): number { + try { + const parts = token.split('.'); + if (parts.length !== 3 || !parts[1]) return Date.now() + 3600 * 1000; + const decoded = JSON.parse(atob(parts[1]!.replace(/-/g, '+').replace(/_/g, '/'))); + if (decoded && typeof decoded === 'object' && typeof (decoded as { exp?: unknown }).exp === 'number') { + return ((decoded as { exp: number }).exp * 1000) - 5 * 60 * 1000; + } + } catch { + // not a JWT + } + return Date.now() + 3600 * 1000; +} diff --git a/packages/provider-cursor/src/auth/oauth_test.ts b/packages/provider-cursor/src/auth/oauth_test.ts new file mode 100644 index 000000000..48742ee50 --- /dev/null +++ b/packages/provider-cursor/src/auth/oauth_test.ts @@ -0,0 +1,60 @@ +import { describe, expect, test } from 'vitest'; + +import { refreshCursorAccessToken, CursorSessionTerminatedError, getTokenExpiry } from './oauth.ts'; +import type { Fetcher } from '@floway-dev/provider'; + +const mkFetcher = (fn: (url: string) => Response | Promise): Fetcher => fn as unknown as Fetcher; + +const jsonRes = (body: unknown, status = 200): Response => + new Response(JSON.stringify(body), { status, headers: { 'content-type': 'application/json' } }); + +describe('refreshCursorAccessToken', () => { + test('returns tokens on 200', async () => { + const fetcher = mkFetcher(async () => jsonRes({ accessToken: 'acc', refreshToken: 'ref' })); + const tokens = await refreshCursorAccessToken('old-ref', fetcher); + expect(tokens.access_token).toBe('acc'); + expect(tokens.refresh_token).toBe('ref'); + expect(tokens.expires_at).toBeGreaterThan(Date.now()); + }); + + test('preserves the old refresh token when the response omits it', async () => { + const fetcher = mkFetcher(async () => jsonRes({ accessToken: 'acc' })); + const tokens = await refreshCursorAccessToken('old-ref', fetcher); + expect(tokens.refresh_token).toBe('old-ref'); + }); + + test('throws CursorSessionTerminatedError on 401', async () => { + const fetcher = mkFetcher(async () => new Response('unauthorized', { status: 401 })); + await expect(refreshCursorAccessToken('ref', fetcher)).rejects.toBeInstanceOf(CursorSessionTerminatedError); + }); + + test('throws CursorSessionTerminatedError on 403', async () => { + const fetcher = mkFetcher(async () => new Response('forbidden', { status: 403 })); + await expect(refreshCursorAccessToken('ref', fetcher)).rejects.toBeInstanceOf(CursorSessionTerminatedError); + }); + + test('throws a plain Error (not terminal) on 500', async () => { + const fetcher = mkFetcher(async () => new Response('boom', { status: 500 })); + await expect(refreshCursorAccessToken('ref', fetcher)).rejects.not.toBeInstanceOf(CursorSessionTerminatedError); + }); + + test('rejects a 200 missing accessToken', async () => { + const fetcher = mkFetcher(async () => jsonRes({ refreshToken: 'r' })); + await expect(refreshCursorAccessToken('ref', fetcher)).rejects.toThrow('missing accessToken'); + }); +}); + +describe('getTokenExpiry', () => { + test('parses JWT exp with a 5-minute margin', () => { + const payload = { exp: Math.floor(Date.now() / 1000) + 3600 }; + const jwt = `header.${btoa(JSON.stringify(payload))}.sig`; + const expiry = getTokenExpiry(jwt); + // exp is 1h out; expiry is exp - 5min, so ~55min from now. + expect(expiry).toBeLessThan(Date.now() + 3600 * 1000); + expect(expiry).toBeGreaterThan(Date.now() + 50 * 60 * 1000); + }); + + test('falls back to ~1h for a non-JWT string', () => { + expect(getTokenExpiry('not-a-jwt')).toBeGreaterThan(Date.now() + 3500 * 1000); + }); +}); diff --git a/packages/provider-cursor/src/auth/poll.ts b/packages/provider-cursor/src/auth/poll.ts new file mode 100644 index 000000000..8c7d9c8f0 --- /dev/null +++ b/packages/provider-cursor/src/auth/poll.ts @@ -0,0 +1,117 @@ +/** + * Cursor PKCE poll-based OAuth login. + * + * Unlike Codex/Claude (callback-paste), Cursor CLI login is poll-based: + * 1. generate an opaque uuid + PKCE verifier/challenge + * 2. open cursor.com/loginDeepControl?challenge=&uuid=&mode=login&redirectTarget=cli + * 3. poll api2.cursor.sh/auth/poll?uuid=&verifier= until it returns tokens + * + * Workers-clean: crypto.getRandomValues / crypto.subtle / crypto.randomUUID, + * bytesToBase64Url from checksum.ts instead of Buffer. + */ + +import { bytesToBase64Url } from '../checksum.ts'; +import { TEXT_ENCODER } from '../proto/encoding.ts'; +import type { Fetcher } from '@floway-dev/provider'; + +const CURSOR_LOGIN_URL = 'https://cursor.com/loginDeepControl'; +const CURSOR_POLL_URL = 'https://api2.cursor.sh/auth/poll'; + +const POLL_MAX_ATTEMPTS = 150; +const POLL_BASE_DELAY = 1000; +const POLL_MAX_DELAY = 10_000; +const POLL_BACKOFF_MULTIPLIER = 1.2; +const POLL_MAX_CONSECUTIVE_ERRORS = 10; + +export interface CursorAuthParams { + verifier: string; + challenge: string; + uuid: string; + loginUrl: string; +} + +export interface CursorPollTokens { + accessToken: string; + refreshToken: string; +} + +async function generatePKCE(): Promise<{ verifier: string; challenge: string }> { + const verifierBytes = new Uint8Array(96); + crypto.getRandomValues(verifierBytes); + const verifier = bytesToBase64Url(verifierBytes); + + const data = TEXT_ENCODER.encode(verifier); + const hashBuffer = new Uint8Array(await crypto.subtle.digest('SHA-256', new Uint8Array(data))); + const challenge = bytesToBase64Url(hashBuffer); + + return { verifier, challenge }; +} + +function sleep(ms: number): Promise { + return new Promise(resolve => setTimeout(resolve, ms)); +} + +/** + * Generate the PKCE pair + uuid + login URL. The operator opens loginUrl in a + * browser; the gateway polls with the returned verifier+uuid. + */ +export async function generateCursorAuthParams(): Promise { + const { verifier, challenge } = await generatePKCE(); + const uuid = crypto.randomUUID(); + + const params = new URLSearchParams({ + challenge, + uuid, + mode: 'login', + redirectTarget: 'cli', + }); + + const loginUrl = `${CURSOR_LOGIN_URL}?${params.toString()}`; + return { verifier, challenge, uuid, loginUrl }; +} + +/** + * Poll the Cursor auth endpoint until the operator completes login. 404 means + * "not ready yet" (backoff and continue); 200 returns the tokens. Other errors + * are counted against a consecutive-error cap so a transient network storm + * doesn't abort a legitimate pending login. + */ +export async function pollCursorAuth(uuid: string, verifier: string, fetcher: Fetcher): Promise { + let delay = POLL_BASE_DELAY; + let consecutiveErrors = 0; + let lastError: string | undefined; + + for (let attempt = 0; attempt < POLL_MAX_ATTEMPTS; attempt++) { + await sleep(delay); + + try { + const response = await fetcher(`${CURSOR_POLL_URL}?uuid=${uuid}&verifier=${verifier}`, {}); + + if (response.status === 404) { + consecutiveErrors = 0; + delay = Math.min(delay * POLL_BACKOFF_MULTIPLIER, POLL_MAX_DELAY); + continue; + } + + if (response.ok) { + const data = (await response.json()) as { accessToken?: string; refreshToken?: string }; + if (typeof data.accessToken !== 'string' || typeof data.refreshToken !== 'string') { + throw new Error('Poll response missing accessToken/refreshToken'); + } + return { accessToken: data.accessToken, refreshToken: data.refreshToken }; + } + + const errorBody = await response.text().catch(() => ''); + throw new Error(`Poll failed: ${response.status}${errorBody ? ` - ${errorBody}` : ''}`); + } catch (err) { + consecutiveErrors++; + lastError = err instanceof Error ? err.message : String(err); + if (consecutiveErrors >= POLL_MAX_CONSECUTIVE_ERRORS) { + throw new Error(`Too many consecutive errors during Cursor auth polling (last: ${lastError})`); + } + delay = Math.min(delay * POLL_BACKOFF_MULTIPLIER, POLL_MAX_DELAY); + } + } + + throw new Error('Cursor authentication polling timeout'); +} diff --git a/packages/provider-cursor/src/auth/poll_test.ts b/packages/provider-cursor/src/auth/poll_test.ts new file mode 100644 index 000000000..074c99f6c --- /dev/null +++ b/packages/provider-cursor/src/auth/poll_test.ts @@ -0,0 +1,39 @@ +import { describe, expect, test } from 'vitest'; + +import { generateCursorAuthParams, pollCursorAuth } from './poll.ts'; +import type { Fetcher } from '@floway-dev/provider'; + +describe('generateCursorAuthParams', () => { + test('produces verifier/challenge/uuid/loginUrl', async () => { + const p = await generateCursorAuthParams(); + expect(p.verifier.length).toBeGreaterThan(0); + expect(p.challenge.length).toBeGreaterThan(0); + expect(p.uuid).toMatch(/^[0-9a-f-]{36}$/i); + expect(p.loginUrl).toContain('cursor.com/loginDeepControl'); + expect(p.loginUrl).toContain(`uuid=${p.uuid}`); + expect(p.loginUrl).toContain(`challenge=${encodeURIComponent(p.challenge)}`); + expect(p.loginUrl).toContain('mode=login'); + expect(p.loginUrl).toContain('redirectTarget=cli'); + }); + + test('challenge is the base64url sha256 of the verifier', async () => { + const p = await generateCursorAuthParams(); + const hash = new Uint8Array(await crypto.subtle.digest('SHA-256', new TextEncoder().encode(p.verifier))); + // base64url of hash, no padding + const expected = btoa(String.fromCharCode(...hash)).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); + expect(p.challenge).toBe(expected); + }); +}); + +describe('pollCursorAuth', () => { + test('returns tokens when the poll resolves 200', async () => { + const fetcher: Fetcher = (async () => + new Response(JSON.stringify({ accessToken: 'a', refreshToken: 'r' }), { + status: 200, + headers: { 'content-type': 'application/json' }, + })) as unknown as Fetcher; + const tokens = await pollCursorAuth('uuid', 'verifier', fetcher); + expect(tokens.accessToken).toBe('a'); + expect(tokens.refreshToken).toBe('r'); + }); +}); diff --git a/packages/provider-cursor/src/checksum.ts b/packages/provider-cursor/src/checksum.ts new file mode 100644 index 000000000..53dfb2f10 --- /dev/null +++ b/packages/provider-cursor/src/checksum.ts @@ -0,0 +1,62 @@ +/** + * x-cursor-checksum header — pure-compute, no machine fingerprint. + * + * Ported from Cursor-To-OpenAI / yet-another-opencode-cursor-auth's + * generateChecksum, Workers-clean: Uint8Array instead of Buffer, Web Crypto + * sha256Hex instead of node:crypto createHash. Async because crypto.subtle + * digest is async — callers precompute once per turn/window and inject the + * string into AgentTransport via getChecksum. + * + * Format: `/` + * where hex1 = sha256(salt[1]).slice(0,8), hex2 = sha256(token).slice(0,8), + * and the timestamp is the current time floored to a 30-minute window, + * divided by 1e6, big-endian into 6 bytes, then run through the XOR/offset + * obfuscation pass. + */ + +import { TEXT_ENCODER } from './proto/encoding.ts'; +import { sha256Hex } from '@floway-dev/platform'; + +/** Base64url-encode a byte buffer (no padding), Workers-clean. */ +export function bytesToBase64Url(bytes: Uint8Array): string { + let bin = ''; + for (const b of bytes) bin += String.fromCharCode(b); + return btoa(bin).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); +} + +function obfuscate(data: Uint8Array): void { + let t = 165; + for (let i = 0; i < data.length; i++) { + data[i] = ((data[i]! ^ t) + i) & 0xff; + t = data[i]!; + } +} + +async function shortSha256Hex(input: string): Promise { + const hex = await sha256Hex(TEXT_ENCODER.encode(input)); + return hex.slice(0, 8); +} + +export async function generateCursorChecksum(token: string): Promise { + const salt = token.split('.'); + + // Floor the current time to a 30-minute window, then encode as 6 big-endian + // bytes (value = ms / 1e6). The windowing makes the checksum stable within + // each half-hour so repeated requests in a turn share a value. + const now = new Date(); + now.setMinutes(30 * Math.floor(now.getMinutes() / 30), 0, 0); + const timestamp = Math.floor(now.getTime() / 1e6); + + const tsBuf = new Uint8Array(6); + let temp = timestamp; + for (let i = 5; i >= 0; i--) { + tsBuf[i] = temp & 0xff; + temp = Math.floor(temp / 256); + } + obfuscate(tsBuf); + + const hex1 = salt[1] ? await shortSha256Hex(salt[1]!) : '00000000'; + const hex2 = await shortSha256Hex(token); + + return `${bytesToBase64Url(tsBuf)}${hex1}/${hex2}`; +} diff --git a/packages/provider-cursor/src/checksum_test.ts b/packages/provider-cursor/src/checksum_test.ts new file mode 100644 index 000000000..74f286a62 --- /dev/null +++ b/packages/provider-cursor/src/checksum_test.ts @@ -0,0 +1,49 @@ +import { describe, expect, test } from 'vitest'; + +import { generateCursorChecksum, bytesToBase64Url } from './checksum.ts'; +import { sha256Hex } from '@floway-dev/platform'; + +describe('bytesToBase64Url', () => { + test('encodes a single zero byte without padding', () => { + expect(bytesToBase64Url(new Uint8Array([0]))).toBe('AA'); + }); + test('is url-safe (0xff -> _w, not /w)', () => { + expect(bytesToBase64Url(new Uint8Array([0xff]))).toBe('_w'); + }); +}); + +describe('generateCursorChecksum', () => { + test('has the / shape', async () => { + const cs = await generateCursorChecksum('a.b.c'); + expect(cs).toMatch(/^[A-Za-z0-9_-]+[0-9a-f]{8}\/[0-9a-f]{8}$/); + }); + + test('deterministic for the same token within a window', async () => { + const a = await generateCursorChecksum('token.x.y'); + const b = await generateCursorChecksum('token.x.y'); + expect(a).toBe(b); + }); + + test('differs for different tokens', async () => { + const a = await generateCursorChecksum('token.one'); + const b = await generateCursorChecksum('token.two'); + expect(a).not.toBe(b); + }); + + test('hex2 matches sha256(token).slice(0,8)', async () => { + const token = 'header.payload.sig'; + const cs = await generateCursorChecksum(token); + const hex2 = cs.split('/')[1]!; + const expected = (await sha256Hex(new TextEncoder().encode(token))).slice(0, 8); + expect(hex2).toBe(expected); + }); + + test('hex1 matches sha256(salt[1]).slice(0,8)', async () => { + const token = 'header.saltbody.sig'; + const cs = await generateCursorChecksum(token); + const tail = cs.split('/')[0]!; + const hex1 = tail.slice(-8); + const expected = (await sha256Hex(new TextEncoder().encode('saltbody'))).slice(0, 8); + expect(hex1).toBe(expected); + }); +}); diff --git a/packages/provider-cursor/src/completions.ts b/packages/provider-cursor/src/completions.ts new file mode 100644 index 000000000..54cf34fb4 --- /dev/null +++ b/packages/provider-cursor/src/completions.ts @@ -0,0 +1,178 @@ +// Cursor Tab (StreamCpp) → OpenAI /v1/completions bridge helpers. +// +// FIM / plain-prompt path: an incoming `/v1/completions` prompt is split into +// prefix/suffix (FIM tokens or plain), turned into a StreamCpp request +// (contents = prefix+suffix, cursor at the prefix/suffix boundary), and the +// model's rewritten region is reduced back to the pure insertion the FIM +// client expects. Zeta marker formats (V0318/V0615) live in zeta-format.ts. + +import { TEXT_ENCODER } from './proto/encoding.ts'; +import type { StreamCppLineRange, StreamCppRequestInput } from './proto/stream-cpp.ts'; + +export interface PrefixSuffix { + prefix: string; + suffix: string; +} + +// Fill-in-the-middle token triples (prefix / suffix / middle), matching the +// formats Zed's fim.rs and other clients emit. Delimiter whitespace (e.g. +// CodeLlama's spaces) is baked into the markers so the extracted prefix/suffix +// stay byte-exact — trimming would corrupt cursor-adjacent whitespace. Order +// matters: more specific (multi-char) markers are tried first. +const FIM_TRIPLES: readonly { pre: string; suf: string; mid: string }[] = [ + { pre: '<|fim▁begin|>', suf: '<|fim▁hole|>', mid: '<|fim▁end|>' }, // DeepSeek + { pre: '<|fim_prefix|>', suf: '<|fim_suffix|>', mid: '<|fim_middle|>' }, // Qwen / CodeGemma + { pre: '<|code_prefix|>', suf: '<|code_suffix|>', mid: '<|code_middle|>' }, // GLM + { pre: '', suf: '', mid: '' }, // StarCoder + { pre: '
 ', suf: ' ', mid: ' ' }, // CodeLlama (spaces are delimiters)
+];
+
+// Parse a completions `prompt` into prefix/suffix. Handles the FIM token
+// triples above, Codestral's reversed [SUFFIX]…[PREFIX]…, and an explicit
+// OpenAI `suffix` field; falls back to treating the whole prompt as the prefix.
+export const parsePrefixSuffix = (prompt: string, explicitSuffix?: string): PrefixSuffix => {
+  if (explicitSuffix !== undefined) return { prefix: prompt, suffix: explicitSuffix };
+
+  for (const { pre, suf, mid } of FIM_TRIPLES) {
+    const pi = prompt.indexOf(pre);
+    const si = prompt.indexOf(suf);
+    const mi = prompt.indexOf(mid);
+    if (pi !== -1 && si > pi && mi > si) {
+      return { prefix: prompt.slice(pi + pre.length, si), suffix: prompt.slice(si + suf.length, mi) };
+    }
+  }
+
+  // Codestral: [SUFFIX]{suffix}[PREFIX]{prefix}
+  const csSuf = prompt.indexOf('[SUFFIX]');
+  const csPre = prompt.indexOf('[PREFIX]');
+  if (csSuf !== -1 && csPre > csSuf) {
+    return { prefix: prompt.slice(csPre + '[PREFIX]'.length), suffix: prompt.slice(csSuf + '[SUFFIX]'.length, csPre) };
+  }
+
+  return { prefix: prompt, suffix: '' };
+};
+
+// Cursor position at the prefix/suffix boundary within contents = prefix+suffix.
+export const cursorAtBoundary = (prefix: string): { line: number; column: number } => {
+  const before = prefix.split('\n');
+  return { line: before.length - 1, column: before[before.length - 1].length };
+};
+
+export const streamCppInputForPrefixSuffix = (ps: PrefixSuffix, opts: { relativePath: string; languageId: string; modelName: string }): StreamCppRequestInput => {
+  const pos = cursorAtBoundary(ps.prefix);
+  return {
+    relativePath: opts.relativePath,
+    contents: ps.prefix + ps.suffix,
+    cursorLine: pos.line,
+    cursorColumn: pos.column,
+    languageId: opts.languageId,
+    modelName: opts.modelName,
+  };
+};
+
+// Apply StreamCpp's rewritten-region `text` to the original file (whole file
+// when no range was emitted). The removed span is bounded by `range` — NOT by
+// text's newline count — so an insertion (where `text` has more lines than the
+// span) does not consume following lines. `endLine`'s inclusive/exclusive sense
+// is inferred from `text`: a trailing newline means the edit ends at a line
+// boundary (exclusive — stop at the start of line `endLine`), otherwise the
+// last line is partial (inclusive — through line `endLine`'s content). Verified
+// against live captures: {2,7}+trailing-\n replaced lines 2-6; {1,6} and a
+// no-trailing-\n extractKey case were inclusive; an insertion sent {1,6} with a
+// longer `text` and must preserve the line after the object.
+export const applyRewrite = (contents: string, range: StreamCppLineRange | undefined, text: string): string => {
+  if (!range) return text;
+  const lines = contents.split('\n');
+  const start = range.startLineNumber - 1;
+  if (start < 0 || start > lines.length) return contents;
+  let startOff = 0;
+  for (let i = 0; i < start; i++) startOff += lines[i].length + 1;
+  let endIdx = range.endLine - 1;
+  if (endIdx < start) endIdx = start;
+  let endOff = 0;
+  for (let i = 0; i < endIdx && i < lines.length; i++) endOff += lines[i].length + 1;
+  if (!text.endsWith('\n') && endIdx < lines.length) endOff += lines[endIdx].length;
+  endOff = Math.min(Math.max(endOff, startOff), contents.length);
+  return contents.slice(0, startOff) + text + contents.slice(endOff);
+};
+
+// Reduce the rewritten file to the pure insertion at the cursor: the text that
+// sits between the original prefix and suffix. Returns '' when the model's edit
+// isn't a clean cursor-anchored insertion (it rewrote prefix/suffix) — the FIM
+// client then simply shows no suggestion. Tolerates a trailing-newline mismatch
+// at EOF (Cursor's rewrite carries the final line's newline; the client's
+// suffix may or may not).
+export const extractInsertion = (ps: PrefixSuffix, range: StreamCppLineRange | undefined, text: string): string => {
+  if (!text) return '';
+  const rewritten = applyRewrite(ps.prefix + ps.suffix, range, text);
+  if (!rewritten.startsWith(ps.prefix)) return '';
+  let mid = rewritten.slice(ps.prefix.length);
+  if (ps.suffix) {
+    if (mid.endsWith(ps.suffix)) mid = mid.slice(0, mid.length - ps.suffix.length);
+    else if (mid.endsWith(`${ps.suffix}\n`)) mid = mid.slice(0, mid.length - ps.suffix.length - 1);
+    else if (ps.suffix.endsWith('\n') && mid.endsWith(ps.suffix.slice(0, -1))) mid = mid.slice(0, mid.length - ps.suffix.length + 1);
+    else return '';
+  }
+  return mid;
+};
+
+// Truncate a completion at the first stop sequence (OpenAI `stop` semantics).
+export const applyStops = (text: string, stop: string | readonly string[] | undefined): string => {
+  if (!stop) return text;
+  const stops = (Array.isArray(stop) ? stop : [stop]).filter((s): s is string => typeof s === 'string' && s.length > 0);
+  let out = text;
+  for (const s of stops) {
+    const i = out.indexOf(s);
+    if (i !== -1) out = out.slice(0, i);
+  }
+  return out;
+};
+
+// A minimal, well-formed OpenAI text_completion body for the passthrough serve.
+// `usage` is required — callers pass estimated token counts so the shared
+// passthrough usage recorder (data-plane/completions/usage.ts) has real numbers
+// to store. Estimates come from `estimateCursorTabTokens` below.
+export interface CompletionsUsage {
+  promptTokens: number;
+  completionTokens: number;
+}
+
+export const completionsResponseBody = (model: string, text: string, usage: CompletionsUsage): string =>
+  JSON.stringify({
+    id: `cmpl-cursor-${crypto.randomUUID()}`,
+    object: 'text_completion',
+    created: Math.floor(Date.now() / 1000),
+    model,
+    choices: [{ text, index: 0, finish_reason: 'stop', logprobs: null }],
+    usage: {
+      prompt_tokens: usage.promptTokens,
+      completion_tokens: usage.completionTokens,
+      total_tokens: usage.promptTokens + usage.completionTokens,
+    },
+  });
+
+// Static per-request token estimator for Cursor Tab. StreamCpp returns no
+// usage of its own, and Cursor's aiserver.v1.AiService/CountTokens takes
+// ~1s per call — too slow to bolt onto autocomplete latency — and ignores
+// its `model_name` field entirely (one fixed BPE tokenizer, probed
+// 2026-07-03). Ratios below are calibrated against that endpoint on
+// representative Lorem-ipsum prose and Python code samples.
+//
+// Cursor Tab is code-domain by design; the code ratio is the default. The
+// prose ratio kicks in only when the client tags a plain-text file
+// (`body.language = 'markdown' | 'txt' | ...`).
+const CURSOR_TAB_BYTES_PER_TOKEN_CODE = 2.55;
+const CURSOR_TAB_BYTES_PER_TOKEN_PROSE = 5.67;
+const PROSE_LANGUAGES: ReadonlySet = new Set(['markdown', 'md', 'mdx', 'rst', 'txt']);
+
+export const estimateCursorTabTokens = (text: string, languageId: string): number => {
+  if (!text) return 0;
+  const bytes = TEXT_ENCODER.encode(text).length;
+  const ratio = PROSE_LANGUAGES.has(languageId.toLowerCase())
+    ? CURSOR_TAB_BYTES_PER_TOKEN_PROSE
+    : CURSOR_TAB_BYTES_PER_TOKEN_CODE;
+  return Math.max(1, Math.ceil(bytes / ratio));
+};
+
+export const languageIdForCompletion = (body: { language?: unknown }): string =>
+  (typeof body.language === 'string' && body.language) || 'plaintext';
diff --git a/packages/provider-cursor/src/completions_test.ts b/packages/provider-cursor/src/completions_test.ts
new file mode 100644
index 000000000..24783a4d3
--- /dev/null
+++ b/packages/provider-cursor/src/completions_test.ts
@@ -0,0 +1,134 @@
+import { describe, expect, test } from 'vitest';
+
+import { applyRewrite, applyStops, completionsResponseBody, cursorAtBoundary, estimateCursorTabTokens, extractInsertion, parsePrefixSuffix } from './completions.ts';
+
+describe('parsePrefixSuffix', () => {
+  test('explicit OpenAI suffix field (no trimming)', () => {
+    expect(parsePrefixSuffix('def f():\n    return ', '\n\nprint(f())')).toEqual({ prefix: 'def f():\n    return ', suffix: '\n\nprint(f())' });
+  });
+  test('StarCoder FIM tokens, byte-exact (keeps trailing space)', () => {
+    expect(parsePrefixSuffix('def f():\n    return \n\nx')).toEqual({ prefix: 'def f():\n    return ', suffix: '\n\nx' });
+  });
+  test('CodeLlama spaces are delimiters', () => {
+    expect(parsePrefixSuffix('
 A B ')).toEqual({ prefix: 'A', suffix: 'B' });
+  });
+  test('Qwen / DeepSeek / GLM triples', () => {
+    expect(parsePrefixSuffix('<|fim_prefix|>P<|fim_suffix|>S<|fim_middle|>')).toEqual({ prefix: 'P', suffix: 'S' });
+    expect(parsePrefixSuffix('<|fim▁begin|>P<|fim▁hole|>S<|fim▁end|>')).toEqual({ prefix: 'P', suffix: 'S' });
+    expect(parsePrefixSuffix('<|code_prefix|>P<|code_suffix|>S<|code_middle|>')).toEqual({ prefix: 'P', suffix: 'S' });
+  });
+  test('Codestral reversed order', () => {
+    expect(parsePrefixSuffix('[SUFFIX]S[PREFIX]P')).toEqual({ prefix: 'P', suffix: 'S' });
+  });
+  test('plain prompt → all prefix', () => {
+    expect(parsePrefixSuffix('hello world')).toEqual({ prefix: 'hello world', suffix: '' });
+  });
+});
+
+describe('cursorAtBoundary', () => {
+  test('line/column at end of prefix', () => {
+    expect(cursorAtBoundary('def f():\n    return ')).toEqual({ line: 1, column: 11 });
+    expect(cursorAtBoundary('x')).toEqual({ line: 0, column: 1 });
+  });
+});
+
+describe('applyRewrite', () => {
+  test('replaces a single line (text ends in newline)', () => {
+    expect(applyRewrite('a\nb\nc', { startLineNumber: 2, endLine: 3 }, 'B\n')).toBe('a\nB\nc');
+  });
+  test('no range → replaces the whole file verbatim', () => {
+    expect(applyRewrite('a\nb', undefined, 'x\ny\n')).toBe('x\ny\n');
+  });
+  // Regressions for the observed Zed bugs: the replaced span must contain
+  // exactly as many newlines as `text`, so the end line's inclusive/exclusive
+  // ambiguity never swallows a following line or duplicates the last one.
+  test('text without trailing newline does not duplicate the landing line', () => {
+    // Restate lines 1-6 as-is (no trailing newline) over a 6-line-then-blanks file.
+    const file = 'def f():\n    a = 1\n    b = 2\n    c = 3\n    d = 4\n    e = 5\n\n\nf()\n';
+    const text = 'def f():\n    a = 1\n    b = 2\n    c = 3\n    d = 4\n    e = 5';
+    expect(applyRewrite(file, { startLineNumber: 1, endLine: 6 }, text)).toBe(file);
+  });
+  test('text with trailing newline does not swallow the following blank line', () => {
+    const file = 'def f():\n    a = 1\n    b = 2\n\n\nf()\n';
+    const text = '    a = 1\n    b = 2\n';
+    expect(applyRewrite(file, { startLineNumber: 2, endLine: 4 }, text)).toBe(file);
+  });
+  test('insertion (text longer than the span) does not eat the following line', () => {
+    // Captured INS1: adding a route must preserve `export default routes;`.
+    const file = "const routes = {\n  home: '/',\n  about: '/about',\n};\n\nexport default routes;\n";
+    const text = "const routes = {\n  home: '/',\n  about: '/about',\n  contact: '/contact',\n};\n\n";
+    const want = "const routes = {\n  home: '/',\n  about: '/about',\n  contact: '/contact',\n};\n\nexport default routes;\n";
+    expect(applyRewrite(file, { startLineNumber: 1, endLine: 6 }, text)).toBe(want);
+  });
+});
+
+describe('extractInsertion', () => {
+  const ps = { prefix: 'def f():\n    return ', suffix: '\n\nprint(f())' };
+  test('pure insertion when prefix + suffix are preserved', () => {
+    // whole-file rewrite (no range) = the file with "a + b" inserted at cursor
+    const text = 'def f():\n    return a + b\n\nprint(f())\n';
+    expect(extractInsertion(ps, undefined, text)).toBe('a + b');
+  });
+  test('empty when the model rewrote the prefix (not a clean insertion)', () => {
+    const text = 'def g():\n    return a + b\n\nprint(f())\n';
+    expect(extractInsertion(ps, undefined, text)).toBe('');
+  });
+  test('empty text → empty insertion', () => {
+    expect(extractInsertion(ps, undefined, '')).toBe('');
+  });
+});
+
+describe('applyStops', () => {
+  test('truncates at the first stop sequence', () => {
+    expect(applyStops('a + b\ndef next', ['\ndef '])).toBe('a + b');
+    expect(applyStops('a + b', undefined)).toBe('a + b');
+    expect(applyStops('a\nb', '\n')).toBe('a');
+  });
+});
+
+describe('estimateCursorTabTokens', () => {
+  test('empty text → 0 (skip the min-1 floor)', () => {
+    expect(estimateCursorTabTokens('', 'python')).toBe(0);
+    expect(estimateCursorTabTokens('', 'markdown')).toBe(0);
+  });
+  test('non-empty text always yields at least 1 token', () => {
+    expect(estimateCursorTabTokens('x', 'python')).toBe(1);
+  });
+  test('code ratio for code / unknown / empty languages (default 2.55 b/tok)', () => {
+    // 1176 UTF-8 bytes / 2.55 → ceil = 462. Ratio calibrated against
+    // aiserver.v1.AiService/CountTokens (probed 2026-07-03).
+    const code = ('def f(n):\n    if n < 2: return n\n    a,b = 0,1\n'
+                  + '    for _ in range(n-1): a,b = b,a+b\n    return b\n\n').repeat(12);
+    const bytes = new TextEncoder().encode(code).length;
+    const expected = Math.ceil(bytes / 2.55);
+    expect(estimateCursorTabTokens(code, 'python')).toBe(expected);
+    expect(estimateCursorTabTokens(code, 'typescript')).toBe(expected);
+    expect(estimateCursorTabTokens(code, 'plaintext')).toBe(expected);
+    expect(estimateCursorTabTokens(code, '')).toBe(expected);
+  });
+  test('prose ratio for markdown/txt (5.67 b/tok, case-insensitive)', () => {
+    const prose = 'The quick brown fox jumps over the lazy dog. '.repeat(20);
+    const bytes = new TextEncoder().encode(prose).length;
+    const expected = Math.ceil(bytes / 5.67);
+    expect(estimateCursorTabTokens(prose, 'markdown')).toBe(expected);
+    expect(estimateCursorTabTokens(prose, 'Markdown')).toBe(expected);
+    expect(estimateCursorTabTokens(prose, 'md')).toBe(expected);
+    expect(estimateCursorTabTokens(prose, 'txt')).toBe(expected);
+  });
+  test('multi-byte characters count as their UTF-8 byte length', () => {
+    // 6 UTF-8 bytes ('é' × 3) → ceil(6/2.55) = 3
+    expect(estimateCursorTabTokens('ééé', 'python')).toBe(3);
+  });
+});
+
+describe('completionsResponseBody', () => {
+  test('emits an OpenAI text_completion with the caller-supplied usage', () => {
+    const body = JSON.parse(completionsResponseBody('cursor-tab', 'hello', { promptTokens: 42, completionTokens: 7 }));
+    expect(body).toMatchObject({
+      object: 'text_completion',
+      model: 'cursor-tab',
+      choices: [{ text: 'hello', index: 0, finish_reason: 'stop', logprobs: null }],
+      usage: { prompt_tokens: 42, completion_tokens: 7, total_tokens: 49 },
+    });
+  });
+});
diff --git a/packages/provider-cursor/src/config.ts b/packages/provider-cursor/src/config.ts
new file mode 100644
index 000000000..ebe12d1e3
--- /dev/null
+++ b/packages/provider-cursor/src/config.ts
@@ -0,0 +1,109 @@
+import type { UpstreamRecord } from '@floway-dev/provider';
+
+// One Cursor account's operator-managed identity, derived from the access
+// token JWT at import. Mutating credentials (refresh_token, access_token,
+// credential health) live in CursorUpstreamState instead.
+export interface CursorAccountIdentity {
+  email: string;
+  userId: string;
+}
+
+// Cursor config is an account pool. v1 always carries exactly one entry —
+// typed as a 1-tuple so callers can index accounts[0] without a nullable
+// cushion. The wire shape stays array-of-accounts so a future fan-out /
+// round-robin pool feature can widen the tuple without a schema migration.
+export interface CursorUpstreamConfig {
+  accounts: [CursorAccountIdentity];
+  // Operator toggle: send every request in Cursor Max Mode (larger context
+  // window, higher usage cost). Absent/false = normal mode. Persisted via
+  // PATCH /api/upstreams/:id { config: { maxMode } } — the accounts pool is
+  // owned by the import/re-import endpoints and never touched by that patch.
+  maxMode?: boolean;
+  // Operator toggle: expose Cursor Tab (StreamCpp) as an OpenAI /v1/completions
+  // edit-prediction model. `model` is the cpp model name sent upstream
+  // (default "fast"). Same settings-only PATCH path as maxMode.
+  tabCompletion?: {
+    enabled: boolean;
+    model?: string;
+  };
+  // Privacy / ghost mode toggle sent to Cursor as the x-ghost-mode data-plane
+  // header. Absent = default on (privacy preserved) — see provider.ts, which
+  // resolves `privacyMode ?? true`. Only the chat data plane honors this; the
+  // model-catalog fetch stays always-private (no user content flows there).
+  privacyMode?: boolean;
+}
+
+export type CursorUpstreamRecord = UpstreamRecord & {
+  provider: 'cursor';
+  config: CursorUpstreamConfig;
+};
+
+function assertCursorUpstreamConfig(value: unknown): asserts value is CursorUpstreamConfig {
+  if (typeof value !== 'object' || value === null || Array.isArray(value)) {
+    throw new TypeError('CursorUpstreamConfig must be a plain object');
+  }
+  const obj = value as Record;
+  for (const key of Object.keys(obj)) {
+    if (key !== 'accounts' && key !== 'maxMode' && key !== 'tabCompletion' && key !== 'privacyMode') {
+      throw new TypeError(`CursorUpstreamConfig has unexpected key '${key}'`);
+    }
+  }
+  if (obj.maxMode !== undefined && typeof obj.maxMode !== 'boolean') {
+    throw new TypeError('CursorUpstreamConfig.maxMode must be a boolean');
+  }
+  if (obj.tabCompletion !== undefined) {
+    const tc = obj.tabCompletion;
+    if (typeof tc !== 'object' || tc === null || Array.isArray(tc)) {
+      throw new TypeError('CursorUpstreamConfig.tabCompletion must be a plain object');
+    }
+    const tco = tc as Record;
+    for (const key of Object.keys(tco)) {
+      if (key !== 'enabled' && key !== 'model') {
+        throw new TypeError(`CursorUpstreamConfig.tabCompletion has unexpected key '${key}'`);
+      }
+    }
+    if (typeof tco.enabled !== 'boolean') {
+      throw new TypeError('CursorUpstreamConfig.tabCompletion.enabled must be a boolean');
+    }
+    if (tco.model !== undefined && (typeof tco.model !== 'string' || tco.model === '')) {
+      throw new TypeError('CursorUpstreamConfig.tabCompletion.model must be a non-empty string');
+    }
+  }
+  if (obj.privacyMode !== undefined && typeof obj.privacyMode !== 'boolean') {
+    throw new TypeError('CursorUpstreamConfig.privacyMode must be a boolean when present');
+  }
+  if (!Array.isArray(obj.accounts)) {
+    throw new TypeError('CursorUpstreamConfig.accounts must be an array');
+  }
+  if (obj.accounts.length !== 1) {
+    throw new TypeError(`CursorUpstreamConfig.accounts must hold exactly one account (got ${obj.accounts.length})`);
+  }
+  const identityKeys: readonly (keyof CursorAccountIdentity)[] = ['email', 'userId'];
+  const allowedKeys = new Set(identityKeys);
+  for (let i = 0; i < obj.accounts.length; i++) {
+    const where = `CursorUpstreamConfig.accounts[${i}]`;
+    const account = obj.accounts[i];
+    if (typeof account !== 'object' || account === null || Array.isArray(account)) {
+      throw new TypeError(`${where} must be a plain object`);
+    }
+    const acc = account as Record;
+    for (const key of Object.keys(acc)) {
+      if (!allowedKeys.has(key)) {
+        throw new TypeError(`${where} has unexpected key '${key}'`);
+      }
+    }
+    for (const key of identityKeys) {
+      const v = acc[key];
+      if (typeof v !== 'string' || v === '') {
+        throw new TypeError(`${where}.${key} must be a non-empty string`);
+      }
+    }
+  }
+}
+
+export function assertCursorUpstreamRecord(record: UpstreamRecord): asserts record is CursorUpstreamRecord {
+  if (record.kind !== 'cursor') {
+    throw new TypeError(`Expected kind 'cursor', got '${record.kind}'`);
+  }
+  assertCursorUpstreamConfig(record.config);
+}
diff --git a/packages/provider-cursor/src/config_test.ts b/packages/provider-cursor/src/config_test.ts
new file mode 100644
index 000000000..fc2a44276
--- /dev/null
+++ b/packages/provider-cursor/src/config_test.ts
@@ -0,0 +1,55 @@
+import { describe, expect, test } from 'vitest';
+
+import { assertCursorUpstreamRecord } from './config.ts';
+import type { UpstreamRecord } from '@floway-dev/provider';
+
+const goodAccount = { email: 'a@b.com', userId: 'u1' };
+const good = { accounts: [goodAccount] };
+
+const wrap = (config: unknown): UpstreamRecord => ({
+  id: 'up',
+  kind: 'cursor',
+  name: 'n',
+  enabled: true,
+  sortOrder: 0,
+  createdAt: '',
+  updatedAt: '',
+  config: config as UpstreamRecord['config'],
+  state: null,
+  flagOverrides: {},
+  disabledPublicModelIds: [],
+  proxyFallbackList: [],
+  modelPrefix: null,
+});
+
+describe('assertCursorUpstreamRecord (config validation)', () => {
+  test('accepts a complete config', () => {
+    expect(() => assertCursorUpstreamRecord(wrap(good))).not.toThrow();
+  });
+
+  test('rejects the wrong kind', () => {
+    expect(() => assertCursorUpstreamRecord({ ...wrap(good), kind: 'codex' })).toThrow("Expected kind 'cursor'");
+  });
+
+  test.each([
+    ['email empty', { accounts: [{ ...goodAccount, email: '' }] }],
+    ['email type', { accounts: [{ ...goodAccount, email: 123 }] }],
+    ['userId missing', { accounts: [{ ...goodAccount, userId: undefined }] }],
+    ['extra field on account', { accounts: [{ ...goodAccount, extra: 1 }] }],
+    ['extra top-level field', { ...good, extra: 1 }],
+    ['accounts not an array', { accounts: goodAccount }],
+    ['empty accounts array', { accounts: [] }],
+    ['two accounts (v1 invariant)', { accounts: [goodAccount, { ...goodAccount, userId: 'u2' }] }],
+  ])('rejects %s', (_label, value) => {
+    expect(() => assertCursorUpstreamRecord(wrap(value))).toThrow();
+  });
+
+  test('accepts an explicit privacyMode boolean', () => {
+    expect(() => assertCursorUpstreamRecord(wrap({ ...good, privacyMode: true }))).not.toThrow();
+    expect(() => assertCursorUpstreamRecord(wrap({ ...good, privacyMode: false }))).not.toThrow();
+  });
+
+  test('rejects a non-boolean privacyMode', () => {
+    expect(() => assertCursorUpstreamRecord(wrap({ ...good, privacyMode: 'true' }))).toThrow(/privacyMode must be a boolean/);
+  });
+});
diff --git a/packages/provider-cursor/src/constants.ts b/packages/provider-cursor/src/constants.ts
new file mode 100644
index 000000000..f3919a944
--- /dev/null
+++ b/packages/provider-cursor/src/constants.ts
@@ -0,0 +1,34 @@
+// All Cursor upstream constants. Do NOT make these operator-configurable —
+// the client-version header is part of how Cursor identifies CLI traffic.
+
+// Cursor's backend. RunSSE + BidiAppend both live here in HTTP/1.1 mode.
+export const CURSOR_BACKEND_BASE = 'https://api2.cursor.sh';
+
+export const CURSOR_RUN_SSE_PATH = '/agent.v1.AgentService/RunSSE';
+export const CURSOR_BIDI_APPEND_PATH = '/aiserver.v1.BidiService/BidiAppend';
+export const CURSOR_USABLE_MODELS_PATH = '/aiserver.v1.AiService/GetUsableModels';
+// AvailableModels carries the client-facing model tooltips. GetUsableModels
+// says which models the account may use; AvailableModels is the only source
+// (prose-only) of each model's context window — see fetchCursorAvailableContext.
+export const CURSOR_AVAILABLE_MODELS_PATH = '/aiserver.v1.AiService/AvailableModels';
+
+// Cursor Tab (Copilot++ / "cpp") lives on a separate, geo-routed backend and
+// is a Connect proto server-stream (not the RunSSE dual channel). Used by the
+// /v1/completions edit-prediction bridge.
+export const CURSOR_GCPP_BACKEND_BASE = 'https://us-only.gcpp.cursor.sh';
+export const CURSOR_STREAM_CPP_PATH = '/aiserver.v1.AiService/StreamCpp';
+
+// The Tab backend is entered as the Cursor IDE (client-type `ide`) and gates on
+// an IDE-style client version — the CLI version string yields empty completions.
+// Bump against the current Cursor desktop release.
+export const CURSOR_TAB_CLIENT_VERSION = '2.2.30';
+
+// Cursor CLI client version we impersonate on the data plane. Pinned from
+// yet-another-opencode-cursor-auth / opencode-cursor-proxy; newer models may
+// gate behind a minimal client version, so bump against the latest CLI tag.
+export const CURSOR_CLIENT_VERSION = 'cli-2025.11.25-d5b3271';
+
+// connect-es UA — matches what the Cursor CLI's connect-rpc client sends.
+export const CURSOR_USER_AGENT = 'connect-es/1.4.0';
+
+export const CURSOR_GRPC_WEB_CONTENT_TYPE = 'application/grpc-web+proto';
diff --git a/packages/provider-cursor/src/context-window.ts b/packages/provider-cursor/src/context-window.ts
new file mode 100644
index 000000000..a4f7c7dd1
Binary files /dev/null and b/packages/provider-cursor/src/context-window.ts differ
diff --git a/packages/provider-cursor/src/context-window_test.ts b/packages/provider-cursor/src/context-window_test.ts
new file mode 100644
index 000000000..a39207486
--- /dev/null
+++ b/packages/provider-cursor/src/context-window_test.ts
@@ -0,0 +1,52 @@
+import { describe, expect, test } from 'vitest';
+
+import { CURSOR_CONTEXT_TTL_MS, clearContextThrottleForTesting, contextCacheKey, readObservedContext, shouldPersistContext, withObservedContext } from './context-window.ts';
+import type { CursorUpstreamState } from './state.ts';
+
+const baseState = (): CursorUpstreamState => ({
+  accounts: [{ userId: 'u1', refresh_token: 'r', state: 'active', state_updated_at: 'x', accessToken: null }],
+});
+
+describe('contextCacheKey', () => {
+  test('separates the two Max-Mode variants', () => {
+    expect(contextCacheKey('claude-opus-4-8', false)).toBe('norm:claude-opus-4-8');
+    expect(contextCacheKey('claude-opus-4-8', true)).toBe('max:claude-opus-4-8');
+  });
+});
+
+describe('withObservedContext / readObservedContext', () => {
+  const now = 1_000_000;
+
+  test('round-trips an observation for the matching mode', () => {
+    const state = withObservedContext(baseState(), 'claude-opus-4-8', false, 200_000, now);
+    expect(readObservedContext(state, 'claude-opus-4-8', false, now)).toBe(200_000);
+    // Different mode is a separate slot — no bleed.
+    expect(readObservedContext(state, 'claude-opus-4-8', true, now)).toBeNull();
+  });
+
+  test('is a pure copy — original state is untouched', () => {
+    const state = baseState();
+    const next = withObservedContext(state, 'gpt-5.5', false, 272_000, now);
+    expect(state.modelContext).toBeUndefined();
+    expect(next.modelContext).toEqual({ 'norm:gpt-5.5': { maxTokens: 272_000, at: now } });
+  });
+
+  test('returns null for an unknown model and for a stale entry', () => {
+    const state = withObservedContext(baseState(), 'gpt-5.5', false, 272_000, now);
+    expect(readObservedContext(state, 'composer-2.5', false, now)).toBeNull();
+    expect(readObservedContext(state, 'gpt-5.5', false, now + CURSOR_CONTEXT_TTL_MS + 1)).toBeNull();
+    expect(readObservedContext(state, 'gpt-5.5', false, now + CURSOR_CONTEXT_TTL_MS - 1)).toBe(272_000);
+  });
+});
+
+describe('shouldPersistContext throttle', () => {
+  test('allows the first attempt then suppresses within the TTL, per model+mode', () => {
+    clearContextThrottleForTesting();
+    const now = 5_000_000;
+    expect(shouldPersistContext('up', 'gpt-5.5', false, now)).toBe(true);
+    expect(shouldPersistContext('up', 'gpt-5.5', false, now + 1000)).toBe(false); // throttled
+    expect(shouldPersistContext('up', 'gpt-5.5', true, now)).toBe(true); // other mode, distinct
+    expect(shouldPersistContext('up2', 'gpt-5.5', false, now)).toBe(true); // other upstream, distinct
+    expect(shouldPersistContext('up', 'gpt-5.5', false, now + CURSOR_CONTEXT_TTL_MS + 1)).toBe(true); // past TTL
+  });
+});
diff --git a/packages/provider-cursor/src/cursor-images.ts b/packages/provider-cursor/src/cursor-images.ts
new file mode 100644
index 000000000..ce0bd0ddd
--- /dev/null
+++ b/packages/provider-cursor/src/cursor-images.ts
@@ -0,0 +1,57 @@
+/**
+ * OpenAI image_url → Cursor SelectedImage parsing.
+ *
+ * Cursor carries input images as inline raw bytes on
+ * UserMessage.selected_context.selected_images[] (proto SelectedImage: mime_type
+ * + bytes data). Only base64 data: URLs are decoded here; remote http(s) URLs
+ * are skipped (fetching them would add an SSRF surface — deferred). Limits match
+ * Cursor's composer-api: at most MAX_CURSOR_IMAGES images, each ≤ MAX bytes.
+ */
+
+import type { ChatCompletionsMessage } from '@floway-dev/protocols/chat-completions';
+
+export interface CursorImageInput {
+  // Raw image bytes (decoded from a data: URL).
+  data: Uint8Array;
+  // e.g. "image/png" — goes to SelectedImage.mime_type.
+  mimeType: string;
+}
+
+// Match Cursor's own composer-api input limits (per OmniRoute's capture).
+export const MAX_CURSOR_IMAGE_BYTES = 1024 * 1024; // 1 MiB per image
+export const MAX_CURSOR_IMAGES = 12;
+
+const DATA_URL_RE = /^data:(image\/[a-zA-Z0-9.+-]+);base64,(.*)$/s;
+
+const decodeBase64 = (b64: string): Uint8Array | null => {
+  try {
+    const bin = atob(b64);
+    const bytes = new Uint8Array(bin.length);
+    for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i);
+    return bytes;
+  } catch {
+    return null;
+  }
+};
+
+/**
+ * Extract data-URL images from a message's content parts, capped at
+ * MAX_CURSOR_IMAGES and dropping any single image over MAX_CURSOR_IMAGE_BYTES.
+ * Remote image_url entries are skipped (no SSRF-guarded fetch yet).
+ */
+export const parseCursorImages = (message: ChatCompletionsMessage): CursorImageInput[] => {
+  const content = message.content;
+  if (!Array.isArray(content)) return [];
+
+  const out: CursorImageInput[] = [];
+  for (const part of content) {
+    if (part.type !== 'image_url') continue;
+    if (out.length >= MAX_CURSOR_IMAGES) break;
+    const match = DATA_URL_RE.exec(part.image_url.url);
+    if (!match) continue; // remote URL — deferred
+    const bytes = decodeBase64(match[2]!);
+    if (!bytes || bytes.length === 0 || bytes.length > MAX_CURSOR_IMAGE_BYTES) continue;
+    out.push({ data: bytes, mimeType: match[1]! });
+  }
+  return out;
+};
diff --git a/packages/provider-cursor/src/cursor-images_test.ts b/packages/provider-cursor/src/cursor-images_test.ts
new file mode 100644
index 000000000..4a7e536df
--- /dev/null
+++ b/packages/provider-cursor/src/cursor-images_test.ts
@@ -0,0 +1,80 @@
+import { describe, expect, test } from 'vitest';
+
+import { MAX_CURSOR_IMAGE_BYTES, MAX_CURSOR_IMAGES, parseCursorImages } from './cursor-images.ts';
+import { encodeSelectedContext, encodeSelectedImage, encodeUserMessage } from './proto/agent-messages.ts';
+import { AgentMode } from './proto/types.ts';
+import { type ChatCompletionsMessage } from '@floway-dev/protocols/chat-completions';
+
+// A 1x1 PNG (real base64) — small, valid bytes.
+const PNG_B64 = 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAAC0lEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==';
+const dataUrl = (mime = 'image/png', b64 = PNG_B64) => `data:${mime};base64,${b64}`;
+const userWith = (...parts: unknown[]): ChatCompletionsMessage => ({ role: 'user', content: parts } as unknown as ChatCompletionsMessage);
+const imgPart = (url: string) => ({ type: 'image_url', image_url: { url } });
+
+describe('parseCursorImages', () => {
+  test('decodes a base64 data URL to bytes + mime', () => {
+    const out = parseCursorImages(userWith({ type: 'text', text: 'hi' }, imgPart(dataUrl())));
+    expect(out).toHaveLength(1);
+    expect(out[0].mimeType).toBe('image/png');
+    expect(out[0].data.length).toBeGreaterThan(0);
+  });
+  test('string content and non-image parts yield nothing', () => {
+    expect(parseCursorImages({ role: 'user', content: 'plain' } as ChatCompletionsMessage)).toEqual([]);
+    expect(parseCursorImages(userWith({ type: 'text', text: 'x' }))).toEqual([]);
+  });
+  test('remote http(s) URLs are skipped (deferred)', () => {
+    expect(parseCursorImages(userWith(imgPart('https://example.com/a.png')))).toEqual([]);
+  });
+  test('malformed base64 is skipped', () => {
+    expect(parseCursorImages(userWith(imgPart('data:image/png;base64,@@@not base64@@@')))).toEqual([]);
+  });
+  test('images over the byte cap are dropped', () => {
+    const big = btoa('x'.repeat(MAX_CURSOR_IMAGE_BYTES + 10));
+    expect(parseCursorImages(userWith(imgPart(dataUrl('image/png', big))))).toEqual([]);
+  });
+  test('caps the number of images', () => {
+    const parts = Array.from({ length: MAX_CURSOR_IMAGES + 5 }, () => imgPart(dataUrl()));
+    expect(parseCursorImages(userWith(...parts))).toHaveLength(MAX_CURSOR_IMAGES);
+  });
+});
+
+// Wire-format helpers.
+const has = (buf: Uint8Array, needle: Uint8Array): boolean => {
+  outer: for (let i = 0; i + needle.length <= buf.length; i++) {
+    for (let j = 0; j < needle.length; j++) if (buf[i + j] !== needle[j]) continue outer;
+    return true;
+  }
+  return false;
+};
+const utf8 = (s: string) => new TextEncoder().encode(s);
+
+describe('SelectedImage encoding', () => {
+  const img = { data: new Uint8Array([1, 2, 3, 4, 5]), mimeType: 'image/jpeg' };
+
+  test('encodeSelectedImage embeds mime_type and raw bytes', () => {
+    const bytes = encodeSelectedImage(img);
+    expect(has(bytes, utf8('image/jpeg'))).toBe(true);
+    expect(has(bytes, img.data)).toBe(true);
+    // field 7 (mime_type) tag = (7<<3)|2 = 0x3a; field 8 (data) tag = (8<<3)|2 = 0x42.
+    expect(has(bytes, new Uint8Array([0x3a]))).toBe(true);
+    expect(has(bytes, new Uint8Array([0x42]))).toBe(true);
+  });
+
+  test('encodeUserMessage attaches selected_context (field 3) only when images present', () => {
+    const withImg = encodeUserMessage('hi', 'mid', AgentMode.AGENT, [img]);
+    const without = encodeUserMessage('hi', 'mid', AgentMode.AGENT);
+    // field 3 (selected_context) tag = (3<<3)|2 = 0x1a
+    expect(has(withImg, new Uint8Array([0x1a]))).toBe(true);
+    expect(has(without, new Uint8Array([0x1a]))).toBe(false);
+    expect(has(withImg, img.data)).toBe(true);
+    expect(withImg.length).toBeGreaterThan(without.length);
+  });
+
+  test('encodeSelectedContext repeats field 1 (selected_images) per image', () => {
+    const one = encodeSelectedContext([img]);
+    const two = encodeSelectedContext([img, img]);
+    // field-1 (selected_images) tag = (1<<3)|2 = 0x0a
+    expect(has(one, new Uint8Array([0x0a]))).toBe(true);
+    expect(two.length).toBeGreaterThan(one.length);
+  });
+});
diff --git a/packages/provider-cursor/src/fetch.ts b/packages/provider-cursor/src/fetch.ts
new file mode 100644
index 000000000..d61093988
--- /dev/null
+++ b/packages/provider-cursor/src/fetch.ts
@@ -0,0 +1,513 @@
+/**
+ * Cursor Chat Completions data-plane call.
+ *
+ * Self-constructs a ProviderStreamResult (async generator of
+ * ProtocolFrame) — bypasses streamingProviderCall,
+ * which hard-requires a Response+text/event-stream, because Cursor's stream is
+ * a synthesized dual-channel (RunSSE read + BidiAppend write) rather than a
+ * single SSE body. The gateway's respond.ts re-encodes these frames downstream.
+ */
+
+import { ensureCursorAccessToken, mintCursorAccessToken } from './access-token-cache.ts';
+import { createAgentTranslator, isComposerModel } from './agent-translate.ts';
+import { AgentTransport } from './agent-transport.ts';
+import { CursorSessionTerminatedError } from './auth/oauth.ts';
+import { generateCursorChecksum } from './checksum.ts';
+import { CURSOR_BACKEND_BASE, CURSOR_CLIENT_VERSION } from './constants.ts';
+import { contextCacheKey, shouldPersistContext, withObservedContext } from './context-window.ts';
+import { parseCursorImages } from './cursor-images.ts';
+import { cursorWireModelId } from './models.ts';
+import { AgentMode, type AgentStreamChunk, type RequestContextEnv, type OpenAIToolDefinition } from './proto/index.ts';
+import { deriveSessionKey, mintSessionKey, decodeToolCallId } from './session-id.ts';
+import type { CursorAccountCredential } from './state.ts';
+import { assertCursorUpstreamState } from './state.ts';
+import { getDurableHttpSession, type DurableHttpSessionHandle } from '@floway-dev/platform';
+import type { ChatCompletionsStreamEvent, ChatCompletionsPayload, ChatCompletionsMessage, ChatCompletionsTool } from '@floway-dev/protocols/chat-completions';
+import { eventFrame, doneFrame, type ProtocolFrame } from '@floway-dev/protocols/common';
+import { getProviderRepo, type ProviderStreamResult, type UpstreamCallOptions, type ProviderModel } from '@floway-dev/provider';
+
+export interface CursorCallEffects {
+  persistRefreshTokenRotation(newRefreshToken: string): Promise;
+  persistTerminalState(state: 'session_terminated' | 'refresh_failed', message: string): Promise;
+}
+
+interface CursorChatCallBase {
+  upstreamId: string;
+  account: CursorAccountCredential;
+  model: ProviderModel;
+  headers: Headers;
+  signal?: AbortSignal;
+  effects: CursorCallEffects;
+  call: UpstreamCallOptions;
+  /** Ghost-mode toggle, resolved by provider.ts from config (default on). */
+  privacyMode: boolean;
+}
+
+export interface CallCursorChatCompletionsOptions extends CursorChatCallBase {
+  body: Omit;
+  // Operator's Max Mode toggle — encoded into ModelDetails.max_mode on the wire.
+  maxMode?: boolean;
+  // Resolved wire model id (variant slug picked from the request's reasoning
+  // effort). Falls back to the model's default wire id.
+  wireModelId?: string;
+}
+
+// Gateway environment reported to Cursor's request_context exec. The gateway
+// has no real workspace (it rejects built-in tool exec), so these are stable
+// placeholders. timezone is the only operator-relevant value.
+const gatewayEnv: RequestContextEnv = {
+  workspacePath: '/workspace',
+  osVersion: 'darwin 24.0.0',
+  shell: '/bin/zsh',
+  timezone: Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC',
+};
+
+const mintAccessToken = (opts: CursorChatCallBase, refreshToken: string) =>
+  mintCursorAccessToken(refreshToken, opts.call.fetcher, opts.effects.persistRefreshTokenRotation);
+
+// Pre-fetch gates + access-token mint + checksum precompute. The checksum is
+// stable within a 30-minute window for a given token, so one precompute per
+// turn is correct.
+const prepareCursorCall = async (
+  opts: CursorChatCallBase,
+): Promise<{ ok: true; accessToken: string; checksum: string } | { ok: false; response: Response }> => {
+  const wrapSynthetic = (response: Response) => opts.call.recordUpstreamLatency(Promise.resolve(response));
+
+  if (opts.account.state !== 'active') {
+    return { ok: false, response: await wrapSynthetic(synthetic503(`Cursor upstream is ${opts.account.state}`)) };
+  }
+
+  try {
+    const entry = await ensureCursorAccessToken(opts.upstreamId, opts.account.userId, refresh => mintAccessToken(opts, refresh));
+    const checksum = await generateCursorChecksum(entry.token);
+    return { ok: true, accessToken: entry.token, checksum };
+  } catch (err) {
+    if (err instanceof CursorSessionTerminatedError) {
+      await opts.effects.persistTerminalState('refresh_failed', err.upstreamMessage);
+      return { ok: false, response: await wrapSynthetic(synthetic503(`Cursor refresh failed: ${err.upstreamMessage}`)) };
+    }
+    throw err;
+  }
+};
+
+// Cursor agent is stateless from the gateway's view: each fresh RunSSE carries
+// an empty conversation state, so the full transcript is inlined into the
+// single user message. System → prefix, history → role-tagged turns.
+//
+// This path serves (a) genuine new conversations and (b) the cold-resume
+// fallback when a live session was lost (cross-instance / evicted / CF cold
+// start). Only live-resume (performResume) carries a tool result over the
+// BidiAppend ExecMcpResult write channel; here on cold-open cursor has zero
+// server-side memory, so past tool rounds are reconstructed into the transcript
+// too — otherwise the model loses what it already called and re-runs tools. They
+// are framed explicitly (which tool, its arguments, its result) so the model
+// reads them as history: the degradation the earlier version avoided came from
+// folding an *unaddressed* JSON blob, not from tool history per se. Tools stay
+// advertised so cursor can still continue the agent loop natively.
+export const flattenMessages = (messages: ChatCompletionsMessage[]): string => {
+  // A tool message carries only tool_call_id + content; recover the tool name
+  // from the assistant tool_calls so its result can be labelled with what it
+  // answers.
+  const toolNameById = new Map();
+  for (const m of messages) {
+    if (m.role === 'assistant' && m.tool_calls) {
+      for (const tc of m.tool_calls) toolNameById.set(tc.id, tc.function.name);
+    }
+  }
+
+  const parts: string[] = [];
+  for (const m of messages) {
+    if (m.role === 'tool') {
+      const mapped = m.tool_call_id ? toolNameById.get(m.tool_call_id) : undefined;
+      const name = mapped ?? m.tool_call_id ?? 'tool';
+      parts.push(`[Tool result: ${name}]\n${messageText(m)}`);
+      continue;
+    }
+
+    if (m.role === 'assistant') {
+      // Text (if any) then one line per tool call it made — an assistant turn
+      // that only carried tool_calls still contributes its calls, not nothing.
+      const lines: string[] = [];
+      const text = messageText(m);
+      if (text) lines.push(text);
+      for (const tc of m.tool_calls ?? []) {
+        lines.push(`→ called ${tc.function.name}(${tc.function.arguments})`);
+      }
+      if (lines.length === 0) continue;
+      parts.push(`[Assistant]\n${lines.join('\n')}`);
+      continue;
+    }
+
+    const text = messageText(m);
+    if (!text) continue;
+    const tag = m.role === 'system' || m.role === 'developer' ? 'System' : m.role === 'user' ? 'User' : m.role;
+    parts.push(`[${tag}]\n${text}`);
+  }
+
+  return parts.join('\n\n');
+};
+
+// Plain-text extraction: string content verbatim, array content's text parts
+// joined, everything else empty (assistant tool_calls are rendered separately).
+const messageText = (m: ChatCompletionsMessage): string => {
+  if (typeof m.content === 'string') return m.content;
+  if (Array.isArray(m.content)) {
+    return m.content.filter(p => p.type === 'text').map(p => p.text).join('\n');
+  }
+  return '';
+};
+
+const toAgentTools = (tools: ChatCompletionsTool[] | null | undefined): OpenAIToolDefinition[] | undefined => {
+  if (!tools || tools.length === 0) return undefined;
+  return tools.map(t => ({
+    type: 'function' as const,
+    function: {
+      name: t.function.name,
+      ...(t.function.description ? { description: t.function.description } : {}),
+      ...(t.function.parameters ? { parameters: t.function.parameters } : {}),
+    },
+  }));
+};
+
+export const syntheticErrorResponse = (status: number, type: string, message: string): Response =>
+  new Response(JSON.stringify({ error: { type, message } }), {
+    status,
+    headers: { 'content-type': 'application/json' },
+  });
+
+const synthetic503 = (message: string): Response =>
+  syntheticErrorResponse(503, 'cursor_upstream_unavailable', message);
+
+export const callCursorChatCompletions = async (
+  opts: CallCursorChatCompletionsOptions,
+): Promise> => {
+  const ready = await prepareCursorCall(opts);
+  if (!ready.ok) return { ok: false, modelKey: opts.model.id, response: ready.response };
+
+  // Session correlation: a tool-result follow-up carries the session id in the
+  // echoed tool_call_id (see session-id.ts).
+  const { sessionKey: derived, isFollowUp } = deriveSessionKey(
+    opts.upstreamId, opts.call.apiKeyId, opts.headers, opts.body.messages,
+  );
+
+  // Follow-up: resume the live RunSSE stream (held by the DurableHttpSession),
+  // seeded by the {requestId, seqno} persisted in D1. A miss / busy claim /
+  // lost socket returns null → fall through to a fresh open (cold-resume:
+  // cursor re-runs the agent loop with the full transcript).
+  if (isFollowUp && derived) {
+    const resumed = await performResume(opts, ready.accessToken, ready.checksum, derived);
+    if (resumed) return resumed;
+  }
+
+  // Open always mints a fresh session key so it never collides with a still-live
+  // session for the derived key (e.g. a racing concurrent follow-up).
+  return await performOpen(opts, ready.accessToken, ready.checksum);
+};
+
+// Claim-lock TTL for the D1 single-flight: long enough to cover a turn's
+// upstream round-trips, short enough that a crashed turn frees the session.
+const CLAIM_TTL_MS = 60_000;
+
+const makeTransport = (
+  opts: CallCursorChatCompletionsOptions,
+  getAuthToken: () => string,
+  checksum: string,
+): AgentTransport =>
+  new AgentTransport({
+    getAuthToken,
+    baseUrl: CURSOR_BACKEND_BASE,
+    env: gatewayEnv,
+    clientVersion: CURSOR_CLIENT_VERSION,
+    privacyMode: opts.privacyMode,
+    getChecksum: () => checksum,
+    // The BidiAppend write channel goes through the proxy-aware Fetcher (which
+    // records upstream latency); the RunSSE read is owned by the
+    // DurableHttpSession, not fetched here.
+    fetch: ((url: string, init: RequestInit) =>
+      opts.call.fetcher(url, init, opts.call.recordUpstreamLatency)) as unknown as typeof fetch,
+  });
+
+// Shared turn driver: pump the transport gen → translator → SSE frames, and
+// dispose of the DurableHttpSession + D1 row at the right boundary:
+//   - mcp exec      → tool_calls + pause: persist {requestId, seqno, leftover}
+//                     to D1, release the handle (keep the socket), stop pulling.
+//   - request_context / built-in → answer/reject on the write channel, continue.
+//   - done          → discard the handle, delete the D1 row.
+//   - error         → discard the handle, delete the D1 row, rethrow (502).
+//
+// Manual gen pulls (never for-await): for-await calls gen.return() on break,
+// which would tear the read loop down mid-pause.
+const buildEvents = (
+  opts: CallCursorChatCompletionsOptions,
+  transport: AgentTransport,
+  gen: AsyncGenerator,
+  translator: ReturnType,
+  sessionKey: string,
+  requestId: string,
+  handle: DurableHttpSessionHandle,
+  first: IteratorResult,
+): AsyncGenerator> => {
+  const repo = getProviderRepo().cursorSessions;
+  return (async function* (): AsyncGenerator> {
+    let paused = false;
+    try {
+      let iterResult = first;
+      while (true) {
+        if (iterResult.done) break;
+        const chunk = iterResult.value;
+        if (chunk.type === 'error') throw new Error(chunk.error ?? 'Cursor agent stream error');
+        if (chunk.type === 'done') break;
+
+        if (chunk.type === 'exec_request' && chunk.execRequest) {
+          const exec = chunk.execRequest;
+          if (exec.type === 'mcp') {
+            for (const ev of translator.translate(chunk)) yield eventFrame(ev);
+            // Pause point: persist the scalars a (possibly cross-instance)
+            // resume needs. The exec id+execId travel in the tool_call_id, so
+            // only requestId/seqno/leftover go to D1.
+            await repo.put({
+              sessionKey,
+              requestId,
+              appendSeqno: Number(transport.seqno),
+              leftover: transport.leftover,
+            });
+            paused = true;
+            break;
+          }
+          if (exec.type === 'request_context') {
+            await transport.sendRequestContextResult(exec.id, exec.execId);
+            iterResult = await gen.next();
+            continue;
+          }
+          await transport.sendRejectedTool(exec, 'Floway gateway cannot execute built-in tools');
+          iterResult = await gen.next();
+          continue;
+        }
+
+        for (const ev of translator.translate(chunk)) yield eventFrame(ev);
+        iterResult = await gen.next();
+      }
+    } catch (err) {
+      await gen.return(undefined).catch(() => {});
+      await handle.discard('stream error').catch(() => {});
+      await repo.delete(sessionKey).catch(() => {});
+      throw err;
+    }
+
+    // gen.return() releases the read lock; leftover was already captured before
+    // the pause yield, so this is safe.
+    await gen.return(undefined).catch(() => {});
+    if (paused) {
+      await handle.release().catch(() => {}); // keep the socket; D1 row persisted
+    } else {
+      await handle.discard('turn ended').catch(() => {});
+      await repo.delete(sessionKey).catch(() => {});
+    }
+
+    for (const ev of translator.finalize()) yield eventFrame(ev);
+    await persistObservedContext(opts, translator.observedMaxTokens());
+    yield doneFrame();
+  })();
+};
+
+// Best-effort persist of the run's observed context window into the upstream
+// state, so the next catalog build prefers it over the tooltip heuristic. A
+// per-isolate throttle keeps this off the hot path (at most once per TTL per
+// model+mode); the write is optimistic-concurrency and any miss/error is
+// swallowed — a lost race just re-attempts on a later request.
+const persistObservedContext = async (opts: CallCursorChatCompletionsOptions, maxTokens: number | null): Promise => {
+  if (maxTokens === null) return;
+  const maxMode = opts.maxMode ?? false;
+  const now = Date.now();
+  if (!shouldPersistContext(opts.upstreamId, opts.model.id, maxMode, now)) return;
+  try {
+    const fresh = await getProviderRepo().upstreams.getById(opts.upstreamId);
+    if (fresh?.kind !== 'cursor') return;
+    assertCursorUpstreamState(fresh.state);
+    if (fresh.state.modelContext?.[contextCacheKey(opts.model.id, maxMode)]?.maxTokens === maxTokens) return;
+    const next = withObservedContext(fresh.state, opts.model.id, maxMode, maxTokens, now);
+    await getProviderRepo().upstreams.saveState(opts.upstreamId, next, { expectedState: fresh.state });
+  } catch {
+    // best-effort: concurrency miss / transient error re-attempts next request
+  }
+};
+
+// A cursor turn opens with pre-output control frames — conversation
+// checkpoints and keep-alive heartbeats cursor emits while the model is still
+// queued/thinking — that arrive before the first real token. Classify the
+// frame the model's first *output* rides on: real text/thinking content, any
+// tool call (incl. the exec_request pause), a kv-blob content frame, or a
+// terminal done/error.
+const isFirstTokenFrame = (chunk: AgentStreamChunk): boolean => {
+  switch (chunk.type) {
+  // Pure control / keep-alive frames: the model has not produced output yet.
+  case 'checkpoint':
+  case 'heartbeat':
+  case 'interaction_query':
+  case 'exec_server_abort':
+  case 'token':
+    return false;
+  // Empty text/thinking deltas carry no token — keep waiting for real content.
+  case 'text':
+  case 'thinking':
+    return Boolean(chunk.content);
+  default:
+    return true;
+  }
+};
+
+// Pull the transport generator to the model's first output frame (or a terminal
+// result), discarding the pre-output control frames. Skipping is output-neutral:
+// those frames translate to zero downstream events, and the returned result is
+// handed to buildEvents unchanged, so exec-pause / seqno persistence / all
+// translation stay there. Wrapping this call in recordUpstreamLatency makes
+// `upstream_success` measure TTFT (request submitted → first token) instead of
+// the BidiAppend write round-trip the earlier wraps saw.
+export const pullToFirstMeaningful = async (
+  gen: AsyncGenerator,
+): Promise> => {
+  for (;;) {
+    const result = await gen.next();
+    if (result.done || isFirstTokenFrame(result.value)) return result;
+  }
+};
+
+// Open a fresh turn: mint a session key, acquire the RunSSE read stream from
+// the DurableHttpSession, send the RunRequest on BidiAppend, then drive.
+const performOpen = async (
+  opts: CallCursorChatCompletionsOptions,
+  accessToken: string,
+  checksum: string,
+): Promise> => {
+  const sessionKey = mintSessionKey(opts.upstreamId, opts.call.apiKeyId);
+  const message = flattenMessages(opts.body.messages);
+  // Inline images ride the current (last) user turn only — Cursor attaches them
+  // to that UserMessage's SelectedContext, and historical turns carry text only.
+  let lastUser: ChatCompletionsMessage | undefined;
+  for (let i = opts.body.messages.length - 1; i >= 0; i--) {
+    if (opts.body.messages[i]!.role === 'user') { lastUser = opts.body.messages[i]; break; }
+  }
+  const images = lastUser ? parseCursorImages(lastUser) : [];
+  // Always advertise the tools: a cold-resume (tool-result follow-up that lost
+  // its session) lets cursor re-run the agent loop natively rather than degrade
+  // to a prompt-folded result.
+  const tools = toAgentTools(opts.body.tools);
+  // AGENT mode keeps the loop open for tool calls; ASK is the direct Q&A shape.
+  const mode = tools && tools.length > 0 ? AgentMode.AGENT : AgentMode.ASK;
+
+  const transport = makeTransport(opts, () => accessToken, checksum);
+  const requestId = crypto.randomUUID();
+  transport.seed(requestId, 0n);
+
+  // Resolve the upstream's proxies so the DurableHttpSession dials RunSSE
+  // through them (the buffered Fetcher can't stream through a proxy). Empty =
+  // direct. Absent resolver (test mocks) = direct.
+  const proxies = (await getProviderRepo().proxies?.resolveForUpstream(opts.upstreamId)) ?? [];
+
+  const broker = getDurableHttpSession();
+  let handle: DurableHttpSessionHandle | null;
+  try {
+    handle = await opts.call.recordUpstreamLatency(
+      broker.acquire(sessionKey, { ...transport.runSseInit(requestId), proxies }, { signal: opts.signal }),
+    );
+  } catch (err) {
+    const m = err instanceof Error ? err.message : String(err);
+    return { ok: false, modelKey: opts.model.id, response: await opts.call.recordUpstreamLatency(Promise.resolve(synthetic503(`Cursor RunSSE dial failed: ${m}`))) };
+  }
+  if (!handle) {
+    return { ok: false, modelKey: opts.model.id, response: await opts.call.recordUpstreamLatency(Promise.resolve(synthetic503('Cursor session broker unavailable'))) };
+  }
+
+  // The RunSSE read is owned by the DurableHttpSession, so a non-200 upstream
+  // status surfaces here (not in the transport). Surface it as a thrown stream
+  // error so the gateway returns a 502.
+  if (handle.status !== 200) {
+    const bodyText = await new Response(handle.body).text().catch(() => '');
+    await handle.discard(`RunSSE status ${handle.status}`).catch(() => {});
+    const events = (async function* (): AsyncGenerator> {
+      throw new Error(`SSE stream failed: ${handle.status} - ${bodyText}`);
+    })();
+    return { ok: true, events, modelKey: opts.model.id };
+  }
+
+  const id = `chatcmpl-cursor-${crypto.randomUUID()}`;
+  const created = Math.floor(Date.now() / 1000);
+  const translator = createAgentTranslator({ id, model: opts.model.id, created, composer: isComposerModel(opts.model.id), sessionKey });
+
+  const gen = transport.openChatStream({ readStream: handle.body, request: { message, model: opts.wireModelId ?? cursorWireModelId(opts.model), tools, mode, maxMode: opts.maxMode, ...(images.length > 0 ? { images } : {}) } });
+  // The first pull sends the RunRequest (BidiAppend) then reads; pull past
+  // cursor's pre-output control frames to the model's first token so the
+  // recorded `upstream_success` latency is TTFT, and satisfy the ok=true
+  // latency assertion. broker.acquire above stays wrapped too so the non-200
+  // early-return path still records a duration; on success this later wrap wins.
+  const first = await opts.call.recordUpstreamLatency(pullToFirstMeaningful(gen));
+  return { ok: true, events: buildEvents(opts, transport, gen, translator, sessionKey, requestId, handle, first), modelKey: opts.model.id };
+};
+
+/**
+ * Resume a tool-result follow-up on the live RunSSE stream held by the
+ * DurableHttpSession, seeded by the {requestId, seqno, leftover} persisted in
+ * D1. Returns null when the session can't be resumed (no row / busy claim /
+ * lost socket) so the caller cold-resumes via a fresh open().
+ *
+ * Token freshness: the access token is re-minted on this request's isolate and
+ * injected via getAuthToken, so a follow-up arriving after the prior turn's
+ * token expired still authenticates.
+ */
+const performResume = async (
+  opts: CallCursorChatCompletionsOptions,
+  accessToken: string,
+  checksum: string,
+  sessionKey: string,
+): Promise | null> => {
+  const repo = getProviderRepo().cursorSessions;
+  // Single-flight: claim atomically locks the row (or returns null if missing /
+  // already claimed by a racing follow-up → cold-resume).
+  const row = await repo.claim(sessionKey, CLAIM_TTL_MS);
+  if (!row) return null;
+
+  const broker = getDurableHttpSession();
+  let handle: DurableHttpSessionHandle | null;
+  try {
+    handle = await opts.call.recordUpstreamLatency(broker.acquire(sessionKey, null, { signal: opts.signal }));
+  } catch {
+    handle = null;
+  }
+  if (!handle) {
+    // Read socket gone (idle-evicted / 15-min cap / cross-instance miss) → drop
+    // the stale row and cold-resume.
+    await repo.delete(sessionKey).catch(() => {});
+    return null;
+  }
+
+  const transport = makeTransport(opts, () => accessToken, checksum);
+  transport.seed(row.requestId, BigInt(row.appendSeqno));
+
+  // Send each tool result on the same stream: the cursor exec ref (id + execId)
+  // is decoded from the client's echoed tool_call_id — no server-side map.
+  const toolMessages = opts.body.messages.filter(m => m.role === 'tool' && typeof m.tool_call_id === 'string');
+  const sendToolResults = async (): Promise => {
+    for (const toolMsg of toolMessages) {
+      const ref = decodeToolCallId(toolMsg.tool_call_id!);
+      if (!ref) continue;
+      const content = typeof toolMsg.content === 'string' ? toolMsg.content : JSON.stringify(toolMsg.content);
+      await transport.sendMcpResultRaw(ref.id, ref.execId, { success: { content } });
+    }
+  };
+
+  const id = `chatcmpl-cursor-${crypto.randomUUID()}`;
+  const created = Math.floor(Date.now() / 1000);
+  const translator = createAgentTranslator({ id, model: opts.model.id, created, composer: isComposerModel(opts.model.id), sessionKey });
+
+  const gen = transport.resumeChatStream({ readStream: handle.body, leftover: row.leftover });
+  // TTFT on a resume spans sending the tool results → the model's first new
+  // token; wrap both so `upstream_success` reflects the model's turnaround, not
+  // just the tool-result write round-trip.
+  const first = await opts.call.recordUpstreamLatency(
+    (async () => {
+      await sendToolResults();
+      return await pullToFirstMeaningful(gen);
+    })(),
+  );
+  return { ok: true, events: buildEvents(opts, transport, gen, translator, sessionKey, row.requestId, handle, first), modelKey: opts.model.id };
+};
diff --git a/packages/provider-cursor/src/fetch_test.ts b/packages/provider-cursor/src/fetch_test.ts
new file mode 100644
index 000000000..8a5ef26b5
--- /dev/null
+++ b/packages/provider-cursor/src/fetch_test.ts
@@ -0,0 +1,320 @@
+import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest';
+
+import { callCursorChatCompletions, flattenMessages, pullToFirstMeaningful, type CursorCallEffects } from './fetch.ts';
+import { addConnectEnvelope, encodeMessageField, encodeStringField, type AgentStreamChunk } from './proto/index.ts';
+import type { CursorAccessTokenEntry, CursorAccountCredential, CursorUpstreamState } from './state.ts';
+import { initDurableHttpSession, resetDurableHttpSessionForTesting, type DurableHttpSession } from '@floway-dev/platform';
+import type { ChatCompletionsStreamEvent } from '@floway-dev/protocols/chat-completions';
+import type { ProtocolFrame } from '@floway-dev/protocols/common';
+import { initProviderRepo, type ProviderModel, type UpstreamRecord } from '@floway-dev/provider';
+import { noopCursorSessionsRepo, noopUpstreamCallOptions, stubProviderModel } from '@floway-dev/test-utils';
+
+// Minimal DurableHttpSession that does the RunSSE fetch via globalThis.fetch
+// (which the tests mock), so the read stream flows through the same channel the
+// suite already controls. Mirrors the in-process impl's open() shape.
+const testDurableHttpSession: DurableHttpSession = {
+  async acquire(_sessionKey, init) {
+    if (!init) return null; // resume miss → cold-resume (claim returns null first anyway)
+    const resp = await globalThis.fetch(init.url, { method: init.method, headers: init.headers, body: init.body as BodyInit });
+    return {
+      status: resp.status,
+      headers: resp.headers,
+      body: resp.body ?? new ReadableStream({ start: c => c.close() }),
+      release: async () => {},
+      discard: async () => {},
+    };
+  },
+};
+
+const makeEffects = (): CursorCallEffects => ({
+  persistRefreshTokenRotation: vi.fn(async () => {}),
+  persistTerminalState: vi.fn(async () => {}),
+});
+
+const farFutureAccessToken: CursorAccessTokenEntry = {
+  token: 'at.cursor.test',
+  expiresAt: Date.now() + 24 * 60 * 60 * 1000,
+  refreshedAt: '2026-01-01T00:00:00Z',
+};
+
+const activeAccount: CursorAccountCredential = {
+  userId: 'u1',
+  refresh_token: 'rt_v1',
+  state: 'active',
+  state_updated_at: '2026-01-01T00:00:00Z',
+  accessToken: farFutureAccessToken,
+};
+
+const model: ProviderModel = stubProviderModel({ id: 'gpt-4o', display_name: 'gpt-4o', endpoints: { chatCompletions: {} } });
+const upstreamId = 'up_a';
+
+const makeRecord = (state: CursorUpstreamState): UpstreamRecord => ({
+  id: upstreamId,
+  kind: 'cursor',
+  name: 'Cursor',
+  enabled: true,
+  sortOrder: 0,
+  createdAt: '2026-01-01T00:00:00.000Z',
+  updatedAt: '2026-01-01T00:00:00.000Z',
+  config: { accounts: [{ email: 'a@b.com', userId: 'u1' }] },
+  state,
+  flagOverrides: {},
+  disabledPublicModelIds: [],
+  proxyFallbackList: [],
+  modelPrefix: null,
+});
+
+let currentRecord: UpstreamRecord;
+
+beforeEach(() => {
+  vi.useRealTimers();
+  initDurableHttpSession(testDurableHttpSession);
+  currentRecord = makeRecord({ accounts: [{ ...activeAccount }] });
+  initProviderRepo(() => ({
+    cursorSessions: noopCursorSessionsRepo(),
+    upstreams: {
+      getById: async () => currentRecord,
+      saveState: async (_id, newState) => {
+        currentRecord = { ...currentRecord, state: newState as CursorUpstreamState };
+        return { updated: true };
+      },
+    },
+  }));
+});
+
+afterEach(() => {
+  resetDurableHttpSessionForTesting();
+  vi.restoreAllMocks();
+});
+
+// AgentServerMessage { field 1: InteractionUpdate { field 1: TextDeltaUpdate { field 1: text } } }
+function textFrame(text: string): Uint8Array {
+  const interactionUpdate = encodeMessageField(1, encodeStringField(1, text));
+  const serverMsg = encodeMessageField(1, interactionUpdate);
+  return addConnectEnvelope(serverMsg);
+}
+
+function turnEndedFrame(): Uint8Array {
+  const interactionUpdate = new Uint8Array([(14 << 3) | 0, 0]);
+  const serverMsg = encodeMessageField(1, interactionUpdate);
+  return addConnectEnvelope(serverMsg);
+}
+
+// AgentServerMessage { field 3: conversation_checkpoint_update } — a pre-output
+// control frame cursor emits before the first token; driveReadLoop yields it as
+// { type: 'checkpoint' }.
+function checkpointFrame(): Uint8Array {
+  const serverMsg = encodeMessageField(3, new Uint8Array([1, 2, 3]));
+  return addConnectEnvelope(serverMsg);
+}
+
+function streamResponse(...frames: Uint8Array[]): Response {
+  const stream = new ReadableStream({
+    start(controller) {
+      for (const f of frames) controller.enqueue(f);
+      controller.close();
+    },
+  });
+  return new Response(stream, { status: 200, headers: { 'content-type': 'application/grpc-web+proto' } });
+}
+
+const mockCursorFetch = (runSse: Response): ReturnType =>
+  vi.spyOn(globalThis, 'fetch').mockImplementation(async input => {
+    const url = String(typeof input === 'string' ? input : (input as URL).toString());
+    if (url.includes('RunSSE')) return runSse;
+    if (url.includes('BidiAppend')) return new Response(new Uint8Array(0), { status: 200 });
+    return new Response('', { status: 404 });
+  });
+
+const collectEvents = async (result: { ok: true; events: AsyncIterable> }): Promise => {
+  const events: ChatCompletionsStreamEvent[] = [];
+  for await (const frame of result.events) {
+    if (frame.type === 'event') events.push(frame.event);
+    else break;
+  }
+  return events;
+};
+
+describe('callCursorChatCompletions', () => {
+  test('refuses non-active state with synthetic 503', async () => {
+    const result = await callCursorChatCompletions({
+      upstreamId,
+      account: { ...activeAccount, state: 'session_terminated' },
+      model,
+      body: { messages: [{ role: 'user', content: 'hi' }] },
+      headers: new Headers(),
+      effects: makeEffects(),
+      privacyMode: true,
+      call: noopUpstreamCallOptions(),
+    });
+    expect(result.ok).toBe(false);
+    if (!result.ok) {
+      expect(result.response.status).toBe(503);
+      expect(await result.response.text()).toMatch(/session_terminated/);
+    }
+  });
+
+  test('streams text + finish_reason=stop on a clean turn', async () => {
+    mockCursorFetch(streamResponse(textFrame('hello'), turnEndedFrame()));
+    const result = await callCursorChatCompletions({
+      upstreamId,
+      account: activeAccount,
+      model,
+      body: { messages: [{ role: 'user', content: 'hi' }] },
+      headers: new Headers(),
+      effects: makeEffects(),
+      privacyMode: true,
+      call: noopUpstreamCallOptions(),
+    });
+    expect(result.ok).toBe(true);
+    if (!result.ok) return;
+
+    const events = await collectEvents(result);
+    const contents = events.map(e => e.choices[0]?.delta?.content ?? '').filter(c => c !== '');
+    expect(contents).toContain('hello');
+    expect(events.some(e => e.choices[0]?.finish_reason === 'stop')).toBe(true);
+    // Trailing zero-usage frame (choices: []) so the gateway counts the request.
+    const usageFrame = events.find(e => e.choices.length === 0 && e.usage);
+    expect(usageFrame?.usage).toEqual({ prompt_tokens: 0, completion_tokens: 0, total_tokens: 0 });
+  });
+
+  test('surfaces a non-ok RunSSE as a thrown stream error', async () => {
+    mockCursorFetch(new Response('upstream down', { status: 503 }));
+    const result = await callCursorChatCompletions({
+      upstreamId,
+      account: activeAccount,
+      model,
+      body: { messages: [{ role: 'user', content: 'hi' }] },
+      headers: new Headers(),
+      effects: makeEffects(),
+      privacyMode: true,
+      call: noopUpstreamCallOptions(),
+    });
+    expect(result.ok).toBe(true);
+    if (!result.ok) return;
+    await expect(collectEvents(result)).rejects.toThrow(/SSE stream failed: 503/);
+  });
+
+  test('records TTFT: the latency wrap resolves to the first token frame, past control frames', async () => {
+    mockCursorFetch(streamResponse(checkpointFrame(), textFrame('hello'), turnEndedFrame()));
+    // Mirror the real recorder: last-settled promise wins. The acquire/BidiAppend
+    // wraps settle first; the pull-to-first-token wrap settles last, so its value
+    // (the first meaningful IteratorResult) is what upstream_success would record.
+    const settled: unknown[] = [];
+    const call = noopUpstreamCallOptions({
+      recordUpstreamLatency: (promise: Promise): Promise => {
+        void promise.then(v => settled.push(v)).catch(() => {});
+        return promise;
+      },
+    });
+    const result = await callCursorChatCompletions({
+      upstreamId, account: activeAccount, model,
+      body: { messages: [{ role: 'user', content: 'hi' }] },
+      headers: new Headers(), effects: makeEffects(), privacyMode: true, call,
+    });
+    expect(result.ok).toBe(true);
+    if (!result.ok) return;
+    const events = await collectEvents(result);
+    expect(events.map(e => e.choices[0]?.delta?.content ?? '').filter(Boolean)).toContain('hello');
+
+    // Only the pull wrap resolves to an IteratorResult (acquire→handle,
+    // BidiAppend→Response). It must carry the text token, not the checkpoint.
+    const pullResults = settled.filter(
+      (v): v is IteratorResult => !!v && typeof v === 'object' && 'done' in v,
+    );
+    expect(pullResults.at(-1)).toEqual({ done: false, value: { type: 'text', content: 'hello' } });
+  });
+});
+
+describe('pullToFirstMeaningful', () => {
+  async function* chunkGen(chunks: AgentStreamChunk[]): AsyncGenerator {
+    for (const c of chunks) yield c;
+  }
+
+  test('skips pre-output control frames and stops at the first text token', async () => {
+    const r = await pullToFirstMeaningful(chunkGen([
+      { type: 'checkpoint' }, { type: 'heartbeat' }, { type: 'text', content: 'hi' },
+    ]));
+    expect(r).toEqual({ done: false, value: { type: 'text', content: 'hi' } });
+  });
+
+  test('skips empty text/thinking deltas, stops at the first non-empty one', async () => {
+    const r = await pullToFirstMeaningful(chunkGen([
+      { type: 'text', content: '' }, { type: 'thinking', content: '' }, { type: 'thinking', content: 'reasoning' },
+    ]));
+    expect(r).toEqual({ done: false, value: { type: 'thinking', content: 'reasoning' } });
+  });
+
+  test('stops at exec_request when the model goes straight to a tool call', async () => {
+    const r = await pullToFirstMeaningful(chunkGen([{ type: 'checkpoint' }, { type: 'exec_request' }]));
+    expect(r.done).toBe(false);
+    expect((r.value as AgentStreamChunk).type).toBe('exec_request');
+  });
+
+  test('stops at a terminal done frame on an empty (all-heartbeat) turn', async () => {
+    const r = await pullToFirstMeaningful(chunkGen([
+      { type: 'heartbeat' }, { type: 'heartbeat' }, { type: 'done' },
+    ]));
+    expect(r).toEqual({ done: false, value: { type: 'done' } });
+  });
+
+  test('returns the done result when the generator ends with only control frames', async () => {
+    const r = await pullToFirstMeaningful(chunkGen([{ type: 'checkpoint' }]));
+    expect(r.done).toBe(true);
+  });
+});
+
+describe('flattenMessages', () => {
+  test('folds a completed tool round with framing and tool-name labels', () => {
+    const out = flattenMessages([
+      { role: 'system', content: 'You are helpful.' },
+      { role: 'user', content: 'weather in Tokyo?' },
+      { role: 'assistant', content: 'Plan: check the weather.', tool_calls: [{ id: 'call_1', type: 'function', function: { name: 'get_weather', arguments: '{"city":"Tokyo"}' } }] },
+      { role: 'tool', tool_call_id: 'call_1', content: '{"temperature":18,"condition":"cloudy"}' },
+      { role: 'assistant', content: 'Tokyo is cloudy, 18C.' },
+      { role: 'user', content: 'thanks' },
+    ]);
+    expect(out).toContain('[System]\nYou are helpful.');
+    expect(out).toContain('[User]\nweather in Tokyo?');
+    expect(out).toContain('[Assistant]\nPlan: check the weather.\n→ called get_weather({"city":"Tokyo"})');
+    expect(out).toContain('[Tool result: get_weather]\n{"temperature":18,"condition":"cloudy"}');
+    expect(out).toContain('[Assistant]\nTokyo is cloudy, 18C.');
+  });
+
+  test('folds a trailing pending tool round instead of dropping it (full reconstruction)', () => {
+    const out = flattenMessages([
+      { role: 'user', content: 'weather?' },
+      { role: 'assistant', content: null, tool_calls: [{ id: 'c', type: 'function', function: { name: 'get_weather', arguments: '{"city":"NYC"}' } }] },
+      { role: 'tool', tool_call_id: 'c', content: '{"t":20}' },
+    ]);
+    expect(out).toContain('→ called get_weather({"city":"NYC"})');
+    expect(out.endsWith('[Tool result: get_weather]\n{"t":20}')).toBe(true);
+  });
+
+  test('renders an assistant turn that carried only tool_calls (no text)', () => {
+    const out = flattenMessages([
+      { role: 'assistant', content: null, tool_calls: [{ id: 'x', type: 'function', function: { name: 'search', arguments: '{}' } }] },
+    ]);
+    expect(out).toBe('[Assistant]\n→ called search({})');
+  });
+
+  test('plain-text conversation keeps the role-tagged shape', () => {
+    const out = flattenMessages([
+      { role: 'system', content: 'sys' },
+      { role: 'user', content: 'hi' },
+      { role: 'assistant', content: 'hello' },
+    ]);
+    expect(out).toBe('[System]\nsys\n\n[User]\nhi\n\n[Assistant]\nhello');
+  });
+
+  test('normalizes tool-result content (array parts) and keeps the frame on empty/unknown', () => {
+    const arr = flattenMessages([
+      { role: 'assistant', content: null, tool_calls: [{ id: 'a', type: 'function', function: { name: 't', arguments: '{}' } }] },
+      { role: 'tool', tool_call_id: 'a', content: [{ type: 'text', text: 'part1' }, { type: 'text', text: 'part2' }] },
+    ]);
+    expect(arr).toContain('[Tool result: t]\npart1\npart2');
+    // Unknown id (no matching assistant tool_call) → labelled by the id itself.
+    const nul = flattenMessages([{ role: 'tool', tool_call_id: 'z', content: null }]);
+    expect(nul).toBe('[Tool result: z]\n');
+  });
+});
diff --git a/packages/provider-cursor/src/index.ts b/packages/provider-cursor/src/index.ts
new file mode 100644
index 000000000..c062682d6
--- /dev/null
+++ b/packages/provider-cursor/src/index.ts
@@ -0,0 +1,18 @@
+export * from './access-token-cache.ts';
+export * from './auth/import.ts';
+export * from './auth/oauth.ts';
+export * from './auth/poll.ts';
+export * from './checksum.ts';
+export * from './config.ts';
+export * from './constants.ts';
+export * from './models.ts';
+export * from './pricing.ts';
+export * from './quota.ts';
+export * from './state.ts';
+export { callStreamCpp } from './stream-cpp-transport.ts';
+export type { StreamCppCallResult } from './stream-cpp-transport.ts';
+export type { StreamCppRequestInput } from './proto/stream-cpp.ts';
+export { createCursorProvider } from './provider.ts';
+export type { CursorCallEffects, CallCursorChatCompletionsOptions } from './fetch.ts';
+export type { AgentTransport, AgentTransportOptions } from './agent-transport.ts';
+export type { ChatCompletionsBoundaryCtx } from './interceptors/chat-completions/types.ts';
diff --git a/packages/provider-cursor/src/interceptors/chat-completions/index.ts b/packages/provider-cursor/src/interceptors/chat-completions/index.ts
new file mode 100644
index 000000000..172b5785c
--- /dev/null
+++ b/packages/provider-cursor/src/interceptors/chat-completions/index.ts
@@ -0,0 +1,14 @@
+// Cursor Chat Completions interceptors. The chain runs inside the provider's
+// callChatCompletions, so the gateway main flow is unaware of it.
+
+import { injectDefaultInstructions } from './inject-default-instructions.ts';
+import { stripUnsupportedFields } from './strip-unsupported-fields.ts';
+import type { ChatCompletionsBoundaryCtx } from './types.ts';
+import type { Interceptor } from '@floway-dev/interceptor';
+
+// Neither interceptor reads a field the other writes, so the listing order is
+// positional.
+export const cursorChatCompletionsChain = (): readonly Interceptor[] => [
+  injectDefaultInstructions,
+  stripUnsupportedFields,
+];
diff --git a/packages/provider-cursor/src/interceptors/chat-completions/inject-default-instructions.ts b/packages/provider-cursor/src/interceptors/chat-completions/inject-default-instructions.ts
new file mode 100644
index 000000000..314b8a594
--- /dev/null
+++ b/packages/provider-cursor/src/interceptors/chat-completions/inject-default-instructions.ts
@@ -0,0 +1,24 @@
+import type { ChatCompletionsBoundaryCtx } from './types.ts';
+
+// Cursor agent runs in AGENT mode and benefits from a system prompt. Native
+// chat-completions callers may omit a system message; source-protocol
+// translators synthesize one only when the caller supplied it. When no system
+// message is present we prepend a neutral default so every request shape
+// satisfies the agent's expectation of grounded instructions.
+export const injectDefaultInstructions = async (
+  ctx: ChatCompletionsBoundaryCtx,
+  _request: object,
+  run: () => Promise,
+): Promise => {
+  const hasSystem = ctx.payload.messages.some(m => m.role === 'system' || m.role === 'developer');
+  if (!hasSystem) {
+    ctx.payload = {
+      ...ctx.payload,
+      messages: [
+        { role: 'system', content: "You're a helpful assistant." },
+        ...ctx.payload.messages,
+      ],
+    };
+  }
+  return await run();
+};
diff --git a/packages/provider-cursor/src/interceptors/chat-completions/inject-default-instructions_test.ts b/packages/provider-cursor/src/interceptors/chat-completions/inject-default-instructions_test.ts
new file mode 100644
index 000000000..0a3b9c091
--- /dev/null
+++ b/packages/provider-cursor/src/interceptors/chat-completions/inject-default-instructions_test.ts
@@ -0,0 +1,40 @@
+import { describe, expect, test } from 'vitest';
+
+import { injectDefaultInstructions } from './inject-default-instructions.ts';
+import type { ChatCompletionsBoundaryCtx } from './types.ts';
+import type { ChatCompletionsPayload } from '@floway-dev/protocols/chat-completions';
+import type { ProviderModel } from '@floway-dev/provider';
+
+const mkCtx = (messages: ChatCompletionsPayload['messages']): ChatCompletionsBoundaryCtx => ({
+  payload: { model: 'm', messages },
+  headers: new Headers(),
+  model: { id: 'm' } as ProviderModel,
+});
+
+describe('injectDefaultInstructions', () => {
+  test('prepends a default system message when none exists', async () => {
+    const ctx = mkCtx([{ role: 'user', content: 'hi' }]);
+    await injectDefaultInstructions(ctx, {}, async () => 'ok');
+    expect(ctx.payload.messages[0]!.role).toBe('system');
+    expect(ctx.payload.messages[1]!.content).toBe('hi');
+  });
+
+  test('leaves messages unchanged when a system message exists', async () => {
+    const ctx = mkCtx([{ role: 'system', content: 'sys' }, { role: 'user', content: 'hi' }]);
+    await injectDefaultInstructions(ctx, {}, async () => 'ok');
+    expect(ctx.payload.messages).toHaveLength(2);
+    expect(ctx.payload.messages[0]!.content).toBe('sys');
+  });
+
+  test('treats developer as a system message', async () => {
+    const ctx = mkCtx([{ role: 'developer', content: 'dev' }, { role: 'user', content: 'hi' }]);
+    await injectDefaultInstructions(ctx, {}, async () => 'ok');
+    expect(ctx.payload.messages).toHaveLength(2);
+  });
+
+  test('runs the inner chain and returns its result', async () => {
+    const ctx = mkCtx([{ role: 'user', content: 'hi' }]);
+    const result = await injectDefaultInstructions(ctx, {}, async () => 'done');
+    expect(result).toBe('done');
+  });
+});
diff --git a/packages/provider-cursor/src/interceptors/chat-completions/strip-unsupported-fields.ts b/packages/provider-cursor/src/interceptors/chat-completions/strip-unsupported-fields.ts
new file mode 100644
index 000000000..dcb9b9e2b
--- /dev/null
+++ b/packages/provider-cursor/src/interceptors/chat-completions/strip-unsupported-fields.ts
@@ -0,0 +1,38 @@
+import type { ChatCompletionsBoundaryCtx } from './types.ts';
+
+// OpenAI Chat Completions fields the Cursor agent endpoint does not honor.
+// Cursor's RunSSE+BidiAppend is an agent loop, not a raw completion API:
+// sampling params (temperature/top_p) and turn limits (max_tokens) are
+// agent-decided, and structured-output / advanced knobs are rejected. We strip
+// them at the Cursor target boundary so source-protocol translators can keep
+// setting them for other providers. `tools` / `tool_choice` / `stream` /
+// `messages` / `model` are retained.
+const CURSOR_UNSUPPORTED_BODY_FIELDS = [
+  'response_format',
+  'seed',
+  'n',
+  'user',
+  'metadata',
+  'frequency_penalty',
+  'presence_penalty',
+  'service_tier',
+  'temperature',
+  'top_p',
+  'max_tokens',
+  'reasoning_effort',
+  'prompt_cache_key',
+  'safety_identifier',
+  'parallel_tool_calls',
+  'stream_options',
+] as const;
+
+export const stripUnsupportedFields = async (
+  ctx: ChatCompletionsBoundaryCtx,
+  _request: object,
+  run: () => Promise,
+): Promise => {
+  const next: Record = { ...(ctx.payload as unknown as Record) };
+  for (const key of CURSOR_UNSUPPORTED_BODY_FIELDS) delete next[key];
+  ctx.payload = next as unknown as typeof ctx.payload;
+  return await run();
+};
diff --git a/packages/provider-cursor/src/interceptors/chat-completions/strip-unsupported-fields_test.ts b/packages/provider-cursor/src/interceptors/chat-completions/strip-unsupported-fields_test.ts
new file mode 100644
index 000000000..f53541a9b
--- /dev/null
+++ b/packages/provider-cursor/src/interceptors/chat-completions/strip-unsupported-fields_test.ts
@@ -0,0 +1,58 @@
+import { describe, expect, test } from 'vitest';
+
+import { stripUnsupportedFields } from './strip-unsupported-fields.ts';
+import type { ChatCompletionsBoundaryCtx } from './types.ts';
+import type { ChatCompletionsPayload } from '@floway-dev/protocols/chat-completions';
+import type { ProviderModel } from '@floway-dev/provider';
+
+const mkCtx = (payload: Partial): ChatCompletionsBoundaryCtx => ({
+  payload: { model: 'm', messages: [{ role: 'user', content: 'hi' }], ...payload } as ChatCompletionsPayload,
+  headers: new Headers(),
+  model: { id: 'm' } as ProviderModel,
+});
+
+describe('stripUnsupportedFields', () => {
+  test('removes unsupported sampling / structured-output knobs', async () => {
+    const ctx = mkCtx({
+      temperature: 0.7,
+      top_p: 0.9,
+      max_tokens: 100,
+      response_format: { type: 'json_object' },
+      seed: 42,
+      n: 2,
+      frequency_penalty: 1,
+      presence_penalty: 1,
+      stream_options: { include_usage: true },
+    });
+    await stripUnsupportedFields(ctx, {}, async () => 'ok');
+    const p = ctx.payload as unknown as Record;
+    expect(p['temperature']).toBeUndefined();
+    expect(p['top_p']).toBeUndefined();
+    expect(p['max_tokens']).toBeUndefined();
+    expect(p['response_format']).toBeUndefined();
+    expect(p['seed']).toBeUndefined();
+    expect(p['n']).toBeUndefined();
+    expect(p['stream_options']).toBeUndefined();
+  });
+
+  test('keeps messages, tools, model, stream, tool_choice', async () => {
+    const ctx = mkCtx({
+      stream: true,
+      tools: [{ type: 'function', function: { name: 'search' } }],
+      tool_choice: 'auto',
+    });
+    await stripUnsupportedFields(ctx, {}, async () => 'ok');
+    const p = ctx.payload as unknown as Record;
+    expect(p['model']).toBe('m');
+    expect(p['stream']).toBe(true);
+    expect(Array.isArray(p['tools'])).toBe(true);
+    expect(p['tool_choice']).toBe('auto');
+    expect(Array.isArray(p['messages'])).toBe(true);
+  });
+
+  test('runs the inner chain and returns its result', async () => {
+    const ctx = mkCtx({});
+    const result = await stripUnsupportedFields(ctx, {}, async () => 'done');
+    expect(result).toBe('done');
+  });
+});
diff --git a/packages/provider-cursor/src/interceptors/chat-completions/types.ts b/packages/provider-cursor/src/interceptors/chat-completions/types.ts
new file mode 100644
index 000000000..dd3341d94
--- /dev/null
+++ b/packages/provider-cursor/src/interceptors/chat-completions/types.ts
@@ -0,0 +1,11 @@
+import type { ChatCompletionsPayload } from '@floway-dev/protocols/chat-completions';
+import type { ProviderModel } from '@floway-dev/provider';
+
+// Boundary ctx for Cursor Chat Completions interceptors. The payload is the
+// OpenAI ChatCompletions request body; interceptors mutate it before fetch.ts
+// flattens it into the Cursor AgentRunRequest.
+export interface ChatCompletionsBoundaryCtx {
+  payload: ChatCompletionsPayload;
+  headers: Headers;
+  readonly model: ProviderModel;
+}
diff --git a/packages/provider-cursor/src/models.ts b/packages/provider-cursor/src/models.ts
new file mode 100644
index 000000000..f7351a1ff
--- /dev/null
+++ b/packages/provider-cursor/src/models.ts
@@ -0,0 +1,429 @@
+import { generateCursorChecksum } from './checksum.ts';
+import { CURSOR_AVAILABLE_MODELS_PATH, CURSOR_BACKEND_BASE, CURSOR_CLIENT_VERSION, CURSOR_USABLE_MODELS_PATH, CURSOR_USER_AGENT } from './constants.ts';
+import { pricingForCursorModelKey } from './pricing.ts';
+import { type Fetcher, type Modality, type UpstreamChatModelConfig, type ProviderModel } from '@floway-dev/provider';
+
+export interface CursorReasoningInfo {
+  supported: readonly string[];
+  default: string;
+}
+
+// One selectable Cursor variant of a base, reduced to the request-routable
+// dimensions (context is excluded — it rides the max_mode proto flag, and the
+// legacySlug is context-independent). `slug` is the wire model id.
+export interface CursorVariantParams {
+  slug: string;
+  effort?: string;
+  thinking?: boolean;
+  fast?: boolean;
+}
+
+export interface CursorRawModel {
+  id: string;
+  display_name: string;
+  aliases?: readonly string[];
+  // Context window (tokens) — undefined leaves cursorRawToProviderModel on 200k.
+  contextWindow?: number;
+  // The concrete Cursor model id to put on the wire (a usable variant slug),
+  // when this base's display id differs from what RunSSE accepts.
+  wireModelId?: string;
+  // Pre-collapse variant/alias ids that map onto this base (registry aliasing).
+  variantIds?: readonly string[];
+  // Reasoning-effort levels the model exposes (→ chat.reasoning.effort).
+  reasoning?: CursorReasoningInfo;
+  // Distinct usable variants (by wire slug) for request→variant routing.
+  variants?: readonly CursorVariantParams[];
+}
+
+// One Cursor "base" model from AvailableModels(useModelParameters=true): the
+// server's own collapse of the ~150 per-variant slugs into ~32 families, each
+// carrying its variant list, per-mode context, reasoning capability, and
+// display name. (Image modality is intentionally not surfaced: cursor's
+// supportsImages field is unreliable and image input is not yet wired.)
+export interface CursorBaseModel {
+  name: string;
+  displayName: string;
+  contextNormal: number | null;
+  contextMax: number | null;
+  reasoning: CursorReasoningInfo | null;
+  variants: (CursorVariantParams & { legacySlug: string; isDefaultNonMaxConfig: boolean; isDefaultMaxConfig: boolean })[];
+  aliasSlugs: string[];
+}
+
+// Shared Connect-JSON headers for the CLI-impersonating catalog RPCs
+// (GetUsableModels + AvailableModels), which take the same auth + client headers.
+const catalogHeaders = (accessToken: string, checksum: string, timezone: string): Record => ({
+  authorization: `Bearer ${accessToken}`,
+  'content-type': 'application/json',
+  accept: 'application/json',
+  'connect-protocol-version': '1',
+  'user-agent': CURSOR_USER_AGENT,
+  'x-cursor-checksum': checksum,
+  'x-cursor-client-version': CURSOR_CLIENT_VERSION,
+  'x-cursor-client-type': 'cli',
+  'x-cursor-timezone': timezone,
+  'x-ghost-mode': 'true',
+});
+
+const isPlainRecord = (v: unknown): v is Record => typeof v === 'object' && v !== null;
+
+// "300k" -> 300000, "1m" -> 1000000, "272k" -> 272000. Null on anything else.
+const scaleTokenCount = (token: string): number | null => {
+  const m = /^([0-9][0-9.]*)\s*([kKmM])$/.exec(token.trim());
+  if (!m) return null;
+  const value = Number.parseFloat(m[1]);
+  if (!Number.isFinite(value)) return null;
+  return Math.round(value * (m[2].toLowerCase() === 'm' ? 1_000_000 : 1_000));
+};
+
+// Fetch + collapse the Cursor catalog. GetUsableModels is the entitlement gate;
+// AvailableModels(useModelParameters) supplies the base grouping, per-mode
+// context, modality/reasoning capability, and display names. Joined into one
+// Floway model per base, with the base's default usable variant slug as the
+// wire id. AvailableModels is best-effort: on any failure we fall back to the
+// raw per-variant list so the upstream keeps working. `maxMode` selects the
+// max-mode context + default variant.
+export const fetchCursorCatalog = async (opts: {
+  accessToken: string;
+  timezone: string;
+  signal?: AbortSignal;
+  fetcher: Fetcher;
+  maxMode?: boolean;
+}): Promise => {
+  const checksum = await generateCursorChecksum(opts.accessToken);
+  const headers = catalogHeaders(opts.accessToken, checksum, opts.timezone);
+
+  const [usable, bases] = await Promise.all([
+    fetchUsableModels(opts.fetcher, headers, opts.signal),
+    fetchCursorBaseModels(opts.fetcher, headers, opts.signal).catch(() => null),
+  ]);
+
+  if (!bases) return usable; // collapse/enrichment unavailable → raw variant list
+  return buildCollapsedCatalog(usable, bases, opts.maxMode ?? false);
+};
+
+const fetchUsableModels = async (fetcher: Fetcher, headers: Record, signal?: AbortSignal): Promise => {
+  const response = await fetcher(`${CURSOR_BACKEND_BASE}${CURSOR_USABLE_MODELS_PATH}`, { method: 'POST', headers, body: '{}', signal });
+  if (!response.ok) {
+    const body = await response.text();
+    throw new Error(`Cursor GetUsableModels fetch failed: ${response.status} ${body.slice(0, 200)}`);
+  }
+  const parsed = (await response.json()) as { models?: unknown };
+  if (!Array.isArray(parsed.models)) throw new Error('Cursor GetUsableModels response missing models array');
+  return parsed.models.map(assertRawModel);
+};
+
+// Parse a context window out of an AvailableModels tooltip's markdown prose.
+// Used as the fallback when parameterDefinitions carries no structured `context`
+// enum. Returns null when the prose names no window (e.g. Auto).
+export const parseContextWindow = (markdown: string): number | null => {
+  const m = /([0-9][0-9.]*\s*[kKmM])\s*context\s*window/.exec(markdown);
+  return m ? scaleTokenCount(m[1].replace(/\s+/g, '')) : null;
+};
+
+// AvailableModels with useModelParameters=true returns the base-grouped catalog
+// (~32 families, each with a `variants` array, `parameterDefinitions`, per-mode
+// tooltips). Authoritative source for the collapse + capability metadata.
+export const fetchCursorBaseModels = async (
+  fetcher: Fetcher,
+  headers: Record,
+  signal?: AbortSignal,
+): Promise => {
+  const response = await fetcher(`${CURSOR_BACKEND_BASE}${CURSOR_AVAILABLE_MODELS_PATH}`, {
+    method: 'POST',
+    headers,
+    body: JSON.stringify({ useModelParameters: true }),
+    signal,
+  });
+  if (!response.ok) {
+    const body = await response.text();
+    throw new Error(`Cursor AvailableModels fetch failed: ${response.status} ${body.slice(0, 200)}`);
+  }
+
+  const parsed = (await response.json()) as { models?: unknown };
+  const out: CursorBaseModel[] = [];
+  if (!Array.isArray(parsed.models)) return out;
+
+  const markdown = (v: unknown): string => (isPlainRecord(v) && typeof v.markdownContent === 'string' ? v.markdownContent : '');
+  for (const entry of parsed.models) {
+    if (!isPlainRecord(entry) || typeof entry.name !== 'string') continue;
+
+    const paramDefs = Array.isArray(entry.parameterDefinitions) ? entry.parameterDefinitions : [];
+    const enumValues = (id: string): string[] | null => {
+      for (const p of paramDefs) {
+        if (!isPlainRecord(p) || p.id !== id) continue;
+        const et = isPlainRecord(p.parameterType) ? p.parameterType.enumParameter : undefined;
+        if (!isPlainRecord(et) || !Array.isArray(et.values)) return null;
+        const vals = et.values.map(v => (isPlainRecord(v) && typeof v.value === 'string' ? v.value : null)).filter((x): x is string => x !== null);
+        return vals.length > 0 ? vals : null;
+      }
+      return null;
+    };
+
+    // Context: prefer the structured `context` enum ([normal, max]); else the
+    // tooltip prose. Some models leave one tooltip blank (e.g. the normal
+    // tooltip empty but the max-mode tooltip naming "272k") — cross-fill so a
+    // context stated in either tooltip is recovered rather than lost to 200k.
+    const contextEnum = enumValues('context');
+    const normalProse = parseContextWindow(markdown(entry.tooltipData));
+    const maxProse = parseContextWindow(markdown(entry.tooltipDataForMaxMode));
+    const contextNormal = contextEnum ? scaleTokenCount(contextEnum[0]) : (normalProse ?? maxProse);
+    const contextMax = contextEnum && contextEnum.length > 1 ? scaleTokenCount(contextEnum[contextEnum.length - 1]) : (maxProse ?? normalProse);
+
+    // Reasoning effort: enum id is `effort` (Anthropic) or `reasoning` (OpenAI).
+    const effortId = paramDefs.some(p => isPlainRecord(p) && p.id === 'effort') ? 'effort'
+      : paramDefs.some(p => isPlainRecord(p) && p.id === 'reasoning') ? 'reasoning' : null;
+    const effortEnum = effortId ? enumValues(effortId) : null;
+
+    const paramValue = (v: Record, id: string): string | undefined => {
+      if (!Array.isArray(v.parameterValues)) return undefined;
+      for (const pv of v.parameterValues) if (isPlainRecord(pv) && pv.id === id && typeof pv.value === 'string') return pv.value;
+      return undefined;
+    };
+
+    const variants: CursorBaseModel['variants'] = [];
+    let defaultEffort: string | null = null;
+    if (Array.isArray(entry.variants)) {
+      for (const v of entry.variants) {
+        if (!isPlainRecord(v) || typeof v.legacySlug !== 'string') continue;
+        const isDefaultNonMaxConfig = v.isDefaultNonMaxConfig === true;
+        const effort = effortId ? paramValue(v, effortId) : undefined;
+        const thinkingStr = paramValue(v, 'thinking');
+        const fastStr = paramValue(v, 'fast');
+        variants.push({
+          slug: v.legacySlug,
+          legacySlug: v.legacySlug,
+          ...(effort !== undefined ? { effort } : {}),
+          ...(thinkingStr !== undefined ? { thinking: thinkingStr === 'true' } : {}),
+          ...(fastStr !== undefined ? { fast: fastStr === 'true' } : {}),
+          isDefaultNonMaxConfig,
+          isDefaultMaxConfig: v.isDefaultMaxConfig === true,
+        });
+        if (isDefaultNonMaxConfig && defaultEffort === null && effort !== undefined) defaultEffort = effort;
+      }
+    }
+
+    let reasoning: CursorReasoningInfo | null = null;
+    if (effortEnum && effortEnum.length > 0) {
+      const fallback = effortEnum.includes('medium') ? 'medium' : effortEnum[Math.floor(effortEnum.length / 2)];
+      reasoning = { supported: effortEnum, default: defaultEffort && effortEnum.includes(defaultEffort) ? defaultEffort : fallback };
+    }
+
+    const aliasSlugs: string[] = [];
+    for (const field of ['legacySlugs', 'idAliases'] as const) {
+      const list = entry[field];
+      if (Array.isArray(list)) for (const s of list) if (typeof s === 'string') aliasSlugs.push(s);
+    }
+
+    out.push({
+      name: entry.name,
+      displayName: typeof entry.clientDisplayName === 'string' ? entry.clientDisplayName : entry.name,
+      contextNormal,
+      contextMax,
+      reasoning,
+      variants,
+      aliasSlugs,
+    });
+  }
+  return out;
+};
+
+// Collapse the base catalog against the usable-id gate into one Floway model per
+// base. The wire id is the mode-appropriate default variant (or any usable
+// variant, or the base name) the account can send. Bases with no usable variant
+// are dropped. Any usable id no base claimed is appended uncollapsed.
+export const buildCollapsedCatalog = (
+  usable: readonly CursorRawModel[],
+  bases: readonly CursorBaseModel[],
+  maxMode: boolean,
+): CursorRawModel[] => {
+  const usableIds = new Set(usable.map(r => r.id));
+  const claimed = new Set();
+  const out: CursorRawModel[] = [];
+
+  for (const b of bases) {
+    const preferred = b.variants.filter(v => (maxMode ? v.isDefaultMaxConfig : v.isDefaultNonMaxConfig));
+    let wire: string | undefined;
+    for (const v of [...preferred, ...b.variants]) {
+      if (usableIds.has(v.legacySlug)) { wire = v.legacySlug; break; }
+    }
+    if (!wire && usableIds.has(b.name)) wire = b.name;
+    if (!wire) continue;
+
+    const variantIds: string[] = [];
+    const variantParams: CursorVariantParams[] = [];
+    const seenSlug = new Set();
+    for (const v of b.variants) {
+      if (!usableIds.has(v.legacySlug)) continue;
+      claimed.add(v.legacySlug);
+      variantIds.push(v.legacySlug);
+      // Dedup by wire slug — the same slug recurs across context sizes with
+      // identical (effort, thinking, fast).
+      if (!seenSlug.has(v.legacySlug)) {
+        seenSlug.add(v.legacySlug);
+        variantParams.push({ slug: v.legacySlug, ...(v.effort !== undefined ? { effort: v.effort } : {}), ...(v.thinking !== undefined ? { thinking: v.thinking } : {}), ...(v.fast !== undefined ? { fast: v.fast } : {}) });
+      }
+    }
+    for (const s of b.aliasSlugs) if (usableIds.has(s)) { variantIds.push(s); claimed.add(s); }
+    claimed.add(wire);
+    claimed.add(b.name);
+
+    const context = maxMode ? (b.contextMax ?? b.contextNormal) : b.contextNormal;
+    out.push({
+      id: b.name,
+      display_name: b.displayName,
+      ...(context !== null ? { contextWindow: context } : {}),
+      wireModelId: wire,
+      variantIds,
+      ...(b.reasoning ? { reasoning: b.reasoning } : {}),
+      ...(variantParams.length > 0 ? { variants: variantParams } : {}),
+    });
+  }
+
+  for (const r of usable) if (!claimed.has(r.id)) out.push(r);
+  return out;
+};
+
+const assertRawModel = (value: unknown): CursorRawModel => {
+  if (!isPlainRecord(value)) throw new TypeError('Cursor model entry is not an object');
+  const modelId = typeof value.modelId === 'string' ? value.modelId : undefined;
+  const displayModelId = typeof value.displayModelId === 'string' ? value.displayModelId : undefined;
+  const id = modelId ?? displayModelId;
+  if (!id) throw new TypeError('Cursor model entry missing modelId/displayModelId');
+
+  const displayName = typeof value.displayName === 'string' ? value.displayName : undefined;
+  const displayNameShort = typeof value.displayNameShort === 'string' ? value.displayNameShort : undefined;
+  const display_name = displayName ?? displayNameShort ?? id;
+
+  const raw: CursorRawModel = { id, display_name };
+
+  if (value.aliases !== undefined) {
+    if (!Array.isArray(value.aliases)) throw new TypeError(`Cursor model entry ${id} aliases not an array`);
+    const out: string[] = [];
+    for (const a of value.aliases) {
+      if (typeof a !== 'string') throw new TypeError(`Cursor model entry ${id} alias not a string`);
+      if (!out.includes(a)) out.push(a);
+    }
+    raw.aliases = out;
+  }
+
+  return raw;
+};
+
+const buildChatConfig = (raw: CursorRawModel): UpstreamChatModelConfig => {
+  // Cursor accepts image input server-side for every model — verified
+  // end-to-end, including models its own (unreliable) supportsImages flag marks
+  // as text-only — so all cursor models advertise image input. Reasoning effort
+  // is surfaced when the model exposes it.
+  const input: Modality[] = ['text', 'image'];
+  const chat: UpstreamChatModelConfig = { modalities: { input, output: ['text'] } };
+  if (raw.reasoning) chat.reasoning = { effort: { supported: [...raw.reasoning.supported], default: raw.reasoning.default } };
+  return chat;
+};
+
+// Cursor exposes only the Chat Completions endpoint (RunSSE+BidiAppend).
+// Pricing is looked up from the per-model notional table in pricing.ts so the
+// dashboard can report value consumed vs. the flat Cursor subscription.
+export const cursorRawToProviderModel = (raw: CursorRawModel, enabledFlags: ReadonlySet): ProviderModel => {
+  const cost = pricingForCursorModelKey(raw.id);
+  // Carry the wire id (so fetch.ts sends the usable variant slug while the
+  // catalog keeps the clean base id) and the pre-collapse variant ids (so the
+  // registry can alias an old per-variant request onto this base).
+  const providerData: { wireModelId?: string; variantIds?: readonly string[]; variants?: readonly CursorVariantParams[] } = {};
+  if (raw.wireModelId && raw.wireModelId !== raw.id) providerData.wireModelId = raw.wireModelId;
+  if (raw.variantIds && raw.variantIds.length > 0) providerData.variantIds = raw.variantIds;
+  if (raw.variants && raw.variants.length > 0) providerData.variants = raw.variants;
+  const chat = buildChatConfig(raw);
+
+  return {
+    id: raw.id,
+    display_name: raw.display_name,
+    owned_by: 'cursor',
+    kind: 'chat',
+    // Context window (normal- or max-mode per the upstream's maxMode toggle);
+    // 200k fallback for models with no window (e.g. Auto) or not covered.
+    limits: { max_context_window_tokens: raw.contextWindow ?? 200_000 },
+    endpoints: { chatCompletions: {} },
+    enabledFlags,
+    chat,
+    ...(providerData.wireModelId || providerData.variantIds || providerData.variants ? { providerData } : {}),
+    ...(cost ? { cost } : {}),
+  };
+};
+
+// The concrete Cursor model id to put on the RunSSE wire for a resolved model:
+// the collapsed base's usable variant slug (from providerData) or the id itself.
+export const cursorWireModelId = (model: ProviderModel): string => {
+  const data = model.providerData;
+  if (isPlainRecord(data) && typeof data.wireModelId === 'string') return data.wireModelId;
+  return model.id;
+};
+
+// Public model id for the Cursor Tab (StreamCpp) edit-prediction completions
+// endpoint. Exposed only when the upstream enables tabCompletion.
+export const CURSOR_TAB_MODEL_ID = 'cursor-tab';
+
+export const cursorTabModel = (enabledFlags: ReadonlySet): ProviderModel => ({
+  id: CURSOR_TAB_MODEL_ID,
+  display_name: 'Cursor Tab',
+  owned_by: 'cursor',
+  kind: 'chat',
+  limits: { max_context_window_tokens: 200_000 },
+  endpoints: { completions: {} },
+  enabledFlags,
+});
+
+// Ordered reasoning-effort scale spanning every vendor's naming, used to map a
+// request's reasoning_effort onto the nearest value a model actually exposes.
+const EFFORT_SCALE = ['none', 'minimal', 'low', 'medium', 'high', 'xhigh', 'extra-high', 'max'];
+
+const nearestEffort = (want: string, supported: readonly string[]): string => {
+  if (supported.includes(want)) return want;
+  const wi = EFFORT_SCALE.indexOf(want);
+  if (wi < 0) return supported[0];
+  let best = supported[0];
+  let bestDist = Infinity;
+  for (const s of supported) {
+    const si = EFFORT_SCALE.indexOf(s);
+    const dist = si < 0 ? Infinity : Math.abs(si - wi);
+    if (dist < bestDist) { bestDist = dist; best = s; }
+  }
+  return best;
+};
+
+// Resolve the wire variant for a request. The collapsed base defaults to one
+// variant; a request's reasoning_effort steers effort (mapped to the nearest
+// exposed level) and, for models with a thinking dimension, thinking on/off
+// ('none' = off, any other effort = on). Context is untouched here — it rides
+// the max_mode proto flag. Falls back to the default wire id when the model has
+// no routable variants or no reasoning_effort was given.
+export const resolveCursorWireModel = (model: ProviderModel, reasoningEffort: string | null | undefined): string => {
+  const defaultSlug = cursorWireModelId(model);
+  const data = model.providerData;
+  if (!reasoningEffort || !isPlainRecord(data) || !Array.isArray(data.variants)) return defaultSlug;
+  const variants = data.variants as CursorVariantParams[];
+  if (variants.length === 0) return defaultSlug;
+
+  const efforts = [...new Set(variants.map(v => v.effort).filter((e): e is string => typeof e === 'string'))];
+  const hasThinking = variants.some(v => typeof v.thinking === 'boolean');
+  const def = variants.find(v => v.slug === defaultSlug);
+
+  const targetEffort = efforts.length > 0 ? nearestEffort(reasoningEffort, efforts) : undefined;
+  const targetThinking = hasThinking ? reasoningEffort !== 'none' : undefined;
+  const targetFast = def?.fast;
+
+  // Weighted match: effort dominates, then thinking, then keep the default fast
+  // setting. Highest score wins; the default variant seeds the best-so-far.
+  const score = (v: CursorVariantParams): number =>
+    (targetEffort !== undefined && v.effort === targetEffort ? 4 : 0) +
+    (targetThinking !== undefined && v.thinking === targetThinking ? 2 : 0) +
+    (targetFast !== undefined && v.fast === targetFast ? 1 : 0);
+
+  let best = def ?? variants[0];
+  let bestScore = def ? score(def) : -1;
+  for (const v of variants) {
+    const s = score(v);
+    if (s > bestScore) { bestScore = s; best = v; }
+  }
+  return best.slug;
+};
diff --git a/packages/provider-cursor/src/models_test.ts b/packages/provider-cursor/src/models_test.ts
new file mode 100644
index 000000000..c67bf3239
--- /dev/null
+++ b/packages/provider-cursor/src/models_test.ts
@@ -0,0 +1,304 @@
+import { describe, expect, test } from 'vitest';
+
+import {
+  buildCollapsedCatalog,
+  cursorRawToProviderModel,
+  cursorWireModelId,
+  fetchCursorBaseModels,
+  fetchCursorCatalog,
+  parseContextWindow,
+  resolveCursorWireModel,
+  type CursorBaseModel,
+  type CursorRawModel,
+} from './models.ts';
+import { type Fetcher, type ProviderModel } from '@floway-dev/provider';
+
+const jsonResponse = (body: unknown): Response =>
+  new Response(JSON.stringify(body), { status: 200, headers: { 'content-type': 'application/json' } });
+
+const noopHeaders: Record = {};
+const fetcherOf = (resp: () => Response | Promise): Fetcher =>
+  (async () => await resp()) as unknown as Fetcher;
+
+const routingFetcher = (routes: {
+  usable: () => Response | Promise;
+  available: () => Response | Promise;
+}): Fetcher =>
+  (async (input: string | URL | Request) => {
+    const url = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url;
+    if (url.includes('GetUsableModels')) return await routes.usable();
+    if (url.includes('AvailableModels')) return await routes.available();
+    throw new Error(`unexpected url ${url}`);
+  }) as unknown as Fetcher;
+
+const usableModels = (ids: string[]): Response =>
+  jsonResponse({ models: ids.map(id => ({ modelId: id, displayName: id })) });
+
+type V = { slug: string; defNonMax?: boolean; defMax?: boolean; effort?: string; thinking?: boolean; fast?: boolean };
+
+// Parsed CursorBaseModel (what buildCollapsedCatalog consumes).
+const pbase = (name: string, variants: V[], extra: Partial = {}): CursorBaseModel => ({
+  name,
+  displayName: name,
+  contextNormal: null,
+  contextMax: null,
+  reasoning: null,
+  variants: variants.map(v => ({
+    slug: v.slug,
+    legacySlug: v.slug,
+    ...(v.effort !== undefined ? { effort: v.effort } : {}),
+    ...(v.thinking !== undefined ? { thinking: v.thinking } : {}),
+    ...(v.fast !== undefined ? { fast: v.fast } : {}),
+    isDefaultNonMaxConfig: v.defNonMax === true,
+    isDefaultMaxConfig: v.defMax === true,
+  })),
+  aliasSlugs: [],
+  ...extra,
+});
+
+describe('parseContextWindow', () => {
+  test('parses k and M suffixes', () => {
+    expect(parseContextWindow('**X**
200k context window')).toBe(200_000); + expect(parseContextWindow('300k context window')).toBe(300_000); + expect(parseContextWindow('1M context window')).toBe(1_000_000); + expect(parseContextWindow('1.5M context window')).toBe(1_500_000); + }); + test('returns null when no context phrase is present', () => { + expect(parseContextWindow('**Auto**
Balanced default.')).toBeNull(); + expect(parseContextWindow('')).toBeNull(); + }); +}); + +describe('cursorRawToProviderModel', () => { + const flags = new Set(); + test('carries context, wire id, and variant ids in providerData', () => { + const m = cursorRawToProviderModel({ id: 'claude-opus-4-8', display_name: 'Claude Opus 4.8', contextWindow: 300_000, wireModelId: 'claude-opus-4-8-high', variantIds: ['claude-opus-4-8-high', 'claude-opus-4-8-max'] }, flags); + expect(m.limits.max_context_window_tokens).toBe(300_000); + expect(m.providerData).toEqual({ wireModelId: 'claude-opus-4-8-high', variantIds: ['claude-opus-4-8-high', 'claude-opus-4-8-max'] }); + }); + test('no providerData when wire id equals id; 200k fallback', () => { + const m = cursorRawToProviderModel({ id: 'default', display_name: 'Auto', wireModelId: 'default' }, flags); + expect(m.providerData).toBeUndefined(); + expect(m.limits.max_context_window_tokens).toBe(200_000); + }); + test('every model advertises image input; reasoning effort added when present', () => { + const withReasoning = cursorRawToProviderModel({ id: 'c', display_name: 'c', reasoning: { supported: ['low', 'medium', 'high'], default: 'high' } }, flags); + expect(withReasoning.chat).toEqual({ + modalities: { input: ['text', 'image'], output: ['text'] }, + reasoning: { effort: { supported: ['low', 'medium', 'high'], default: 'high' } }, + }); + }); + test('a model with no reasoning still advertises image input', () => { + expect(cursorRawToProviderModel({ id: 'd', display_name: 'd' }, flags).chat).toEqual({ + modalities: { input: ['text', 'image'], output: ['text'] }, + }); + }); +}); + +describe('cursorWireModelId', () => { + test('reads providerData.wireModelId, else the id', () => { + expect(cursorWireModelId({ id: 'claude-opus-4-8', providerData: { wireModelId: 'claude-opus-4-8-high' } } as ProviderModel)).toBe('claude-opus-4-8-high'); + expect(cursorWireModelId({ id: 'gpt-5.5' } as ProviderModel)).toBe('gpt-5.5'); + }); +}); + +describe('resolveCursorWireModel', () => { + const flags = new Set(); + // thinking + effort model (opus-like) + const opus = cursorRawToProviderModel({ + id: 'opus', display_name: 'Opus', wireModelId: 'opus-thinking-high', + variants: [ + { slug: 'opus-low', effort: 'low', thinking: false, fast: false }, + { slug: 'opus-high', effort: 'high', thinking: false, fast: false }, + { slug: 'opus-thinking-low', effort: 'low', thinking: true, fast: false }, + { slug: 'opus-thinking-high', effort: 'high', thinking: true, fast: false }, + ], + }, flags); + // thinking-only model (sonnet-4-5-like) + const sonnet = cursorRawToProviderModel({ + id: 'sonnet', display_name: 'Sonnet', wireModelId: 'sonnet-thinking', + variants: [{ slug: 'sonnet', thinking: false }, { slug: 'sonnet-thinking', thinking: true }], + }, flags); + // effort-only model (gpt-like, incl 'none') + const gpt = cursorRawToProviderModel({ + id: 'gpt', display_name: 'GPT', wireModelId: 'gpt-high', + variants: [{ slug: 'gpt-none', effort: 'none' }, { slug: 'gpt-low', effort: 'low' }, { slug: 'gpt-high', effort: 'high' }], + }, flags); + // single-variant model (kimi-like) + const kimi = cursorRawToProviderModel({ id: 'kimi', display_name: 'Kimi', wireModelId: 'kimi', variants: [{ slug: 'kimi' }] }, flags); + + test('no reasoning_effort → default wire', () => { + expect(resolveCursorWireModel(opus, null)).toBe('opus-thinking-high'); + expect(resolveCursorWireModel(opus, undefined)).toBe('opus-thinking-high'); + }); + test('effort steers effort; thinking stays on for a non-none effort', () => { + expect(resolveCursorWireModel(opus, 'high')).toBe('opus-thinking-high'); + expect(resolveCursorWireModel(opus, 'low')).toBe('opus-thinking-low'); + expect(resolveCursorWireModel(opus, 'xhigh')).toBe('opus-thinking-high'); // nearest to 'high' + }); + test("'none' turns thinking off and drops to lowest effort", () => { + expect(resolveCursorWireModel(opus, 'none')).toBe('opus-low'); + }); + test('thinking-only model toggles thinking by effort presence', () => { + expect(resolveCursorWireModel(sonnet, 'high')).toBe('sonnet-thinking'); + expect(resolveCursorWireModel(sonnet, 'none')).toBe('sonnet'); + expect(resolveCursorWireModel(sonnet, null)).toBe('sonnet-thinking'); + }); + test('effort-only model maps effort including none', () => { + expect(resolveCursorWireModel(gpt, 'none')).toBe('gpt-none'); + expect(resolveCursorWireModel(gpt, 'low')).toBe('gpt-low'); + expect(resolveCursorWireModel(gpt, 'medium')).toBe('gpt-low'); // nearest + }); + test('single-variant model always returns its default', () => { + expect(resolveCursorWireModel(kimi, 'high')).toBe('kimi'); + expect(resolveCursorWireModel(kimi, 'none')).toBe('kimi'); + }); +}); + +describe('buildCollapsedCatalog', () => { + const usable: CursorRawModel[] = [ + 'claude-opus-4-8-high', 'claude-opus-4-8-max', 'composer-2.5', 'default', 'kimi-k2.5', + ].map(id => ({ id, display_name: id })); + + const bases = [ + pbase('claude-opus-4-8', [{ slug: 'claude-opus-4-8-high', defNonMax: true }, { slug: 'claude-opus-4-8-max', defMax: true }], { + displayName: 'Claude Opus 4.8', contextNormal: 300_000, contextMax: 1_000_000, + reasoning: { supported: ['low', 'medium', 'high', 'xhigh', 'max'], default: 'high' }, + }), + pbase('composer-2.5', [{ slug: 'composer-2.5-fast', defNonMax: true, defMax: true }, { slug: 'composer-2.5' }], { displayName: 'Composer 2.5', contextNormal: 200_000, contextMax: 200_000 }), + pbase('default', [{ slug: 'default', defNonMax: true, defMax: true }], { displayName: 'Auto' }), + pbase('phantom', [{ slug: 'phantom-x', defNonMax: true }], { contextNormal: 128_000 }), + ]; + + test('collapses to one model per usable base, carrying metadata (normal mode)', () => { + const out = buildCollapsedCatalog(usable, bases, false); + const byId = Object.fromEntries(out.map(r => [r.id, r])); + expect(out).toHaveLength(4); // 3 collapsed + kimi leftover; phantom dropped + + const opus = byId['claude-opus-4-8']; + expect(opus.display_name).toBe('Claude Opus 4.8'); + expect(opus.wireModelId).toBe('claude-opus-4-8-high'); + expect(opus.contextWindow).toBe(300_000); + expect(opus.reasoning).toEqual({ supported: ['low', 'medium', 'high', 'xhigh', 'max'], default: 'high' }); + expect(opus.variantIds).toEqual(['claude-opus-4-8-high', 'claude-opus-4-8-max']); + + expect(byId['composer-2.5'].wireModelId).toBe('composer-2.5'); // fast not usable → fallback + expect(byId['kimi-k2.5'].wireModelId).toBeUndefined(); // leftover + }); + + test('max mode picks the default-max variant + max context', () => { + const opus = buildCollapsedCatalog(usable, bases, true).find(r => r.id === 'claude-opus-4-8')!; + expect(opus.wireModelId).toBe('claude-opus-4-8-max'); + expect(opus.contextWindow).toBe(1_000_000); + }); +}); + +// A realistic AvailableModels(useModelParameters) entry. +const tt = (s: string) => ({ markdownContent: s }); +const rawEntry = (o: { + name: string; heading?: string; clientDisplayName?: string; + ctxEnum?: [string, string]; ctxTooltipNormal?: string; ctxTooltipMax?: string; + effortId?: 'effort' | 'reasoning'; efforts?: string[]; defaultEffort?: string; + variants: { slug: string; defNonMax?: boolean; defMax?: boolean }[]; +}) => { + const paramDefs: unknown[] = []; + if (o.ctxEnum) paramDefs.push({ id: 'context', parameterType: { enumParameter: { values: o.ctxEnum.map(v => ({ value: v })) } } }); + if (o.effortId && o.efforts) paramDefs.push({ id: o.effortId, parameterType: { enumParameter: { values: o.efforts.map(v => ({ value: v })) } } }); + return { + name: o.name, + ...(o.clientDisplayName ? { clientDisplayName: o.clientDisplayName } : {}), + tooltipData: tt(`${o.heading ? `**${o.heading}**
` : ''}desc${o.ctxTooltipNormal ? `

${o.ctxTooltipNormal} context window` : ''}`), + tooltipDataForMaxMode: tt(`${o.heading ? `**${o.heading}**
` : ''}desc${o.ctxTooltipMax ? `

${o.ctxTooltipMax} context window` : ''}`), + parameterDefinitions: paramDefs, + variants: o.variants.map(v => ({ + legacySlug: v.slug, + ...(v.defNonMax ? { isDefaultNonMaxConfig: true } : {}), + ...(v.defMax ? { isDefaultMaxConfig: true } : {}), + ...(o.effortId && o.defaultEffort && v.defNonMax ? { parameterValues: [{ id: o.effortId, value: o.defaultEffort }] } : {}), + })), + }; +}; + +describe('fetchCursorBaseModels', () => { + test('uses clientDisplayName for the display name, falling back to the id', async () => { + const bases = await fetchCursorBaseModels(fetcherOf(() => jsonResponse({ + models: [ + rawEntry({ name: 'claude-opus-4-8', clientDisplayName: 'Opus 4.8', variants: [{ slug: 'claude-opus-4-8-high', defNonMax: true }] }), + rawEntry({ name: 'gpt-5.2', variants: [{ slug: 'y', defNonMax: true }] }), // no clientDisplayName → id + ], + })), noopHeaders); + const byName = Object.fromEntries(bases.map(b => [b.name, b])); + expect(byName['claude-opus-4-8'].displayName).toBe('Opus 4.8'); + expect(byName['gpt-5.2'].displayName).toBe('gpt-5.2'); + }); + + test('prefers the structured context enum, falls back to tooltip prose', async () => { + const bases = await fetchCursorBaseModels(fetcherOf(() => jsonResponse({ + models: [ + rawEntry({ name: 'opus', ctxEnum: ['300k', '1m'], ctxTooltipNormal: '999k', ctxTooltipMax: '999k', variants: [{ slug: 'a', defNonMax: true }] }), + rawEntry({ name: 'gemini', ctxTooltipNormal: '200k', ctxTooltipMax: '1M', variants: [{ slug: 'b', defNonMax: true }] }), // no enum → tooltip + ], + })), noopHeaders); + const byName = Object.fromEntries(bases.map(b => [b.name, b])); + expect(byName['opus'].contextNormal).toBe(300_000); // enum, not tooltip 999k + expect(byName['opus'].contextMax).toBe(1_000_000); + expect(byName['gemini'].contextNormal).toBe(200_000); // tooltip fallback + expect(byName['gemini'].contextMax).toBe(1_000_000); + }); + + test('recovers a context stated only in the max-mode tooltip (normal tooltip blank)', async () => { + // gpt-5.3-codex shape: normal tooltip carries no context, max tooltip says 272k. + const bases = await fetchCursorBaseModels(fetcherOf(() => jsonResponse({ + models: [ + rawEntry({ name: 'codex', ctxTooltipMax: '272k', variants: [{ slug: 'a', defNonMax: true }] }), + ], + })), noopHeaders); + expect(bases[0].contextNormal).toBe(272_000); // recovered from the max tooltip + expect(bases[0].contextMax).toBe(272_000); + }); + + test('parses reasoning effort enum + default from the default-non-max variant', async () => { + const bases = await fetchCursorBaseModels(fetcherOf(() => jsonResponse({ + models: [ + rawEntry({ name: 'opus', effortId: 'effort', efforts: ['low', 'medium', 'high', 'xhigh', 'max'], defaultEffort: 'high', variants: [{ slug: 'a', defNonMax: true }] }), + rawEntry({ name: 'gpt', effortId: 'reasoning', efforts: ['none', 'low', 'medium', 'high'], variants: [{ slug: 'b', defNonMax: true }] }), // no defaultEffort → 'medium' + rawEntry({ name: 'kimi', variants: [{ slug: 'c', defNonMax: true }] }), // no effort param → null + ], + })), noopHeaders); + const byName = Object.fromEntries(bases.map(b => [b.name, b])); + expect(byName['opus'].reasoning).toEqual({ supported: ['low', 'medium', 'high', 'xhigh', 'max'], default: 'high' }); + expect(byName['gpt'].reasoning).toEqual({ supported: ['none', 'low', 'medium', 'high'], default: 'medium' }); + expect(byName['kimi'].reasoning).toBeNull(); + }); +}); + +describe('fetchCursorCatalog', () => { + const opts = (fetcher: Fetcher, maxMode?: boolean) => ({ accessToken: 'tok', timezone: 'UTC', fetcher, maxMode }); + const available = () => jsonResponse({ + models: [ + rawEntry({ name: 'claude-opus-4-8', clientDisplayName: 'Opus 4.8', ctxEnum: ['300k', '1m'], effortId: 'effort', efforts: ['low', 'high'], defaultEffort: 'high', variants: [{ slug: 'claude-opus-4-8-high', defNonMax: true }] }), + ], + }); + + test('collapses with display name, context, reasoning', async () => { + const raw = await fetchCursorCatalog(opts(routingFetcher({ usable: () => usableModels(['claude-opus-4-8-high']), available }))); + expect(raw).toHaveLength(1); + const opus = raw[0]; + expect(opus.id).toBe('claude-opus-4-8'); + expect(opus.display_name).toBe('Opus 4.8'); + expect(opus.wireModelId).toBe('claude-opus-4-8-high'); + expect(opus.contextWindow).toBe(300_000); + expect(opus.reasoning).toEqual({ supported: ['low', 'high'], default: 'high' }); + }); + + test('max mode reports the max context', async () => { + const raw = await fetchCursorCatalog(opts(routingFetcher({ usable: () => usableModels(['claude-opus-4-8-high']), available }), true)); + expect(raw[0].contextWindow).toBe(1_000_000); + }); + + test('falls back to the raw per-variant list when AvailableModels fails', async () => { + const raw = await fetchCursorCatalog(opts(routingFetcher({ usable: () => usableModels(['claude-opus-4-8-high', 'gpt-5.5-high']), available: () => new Response('nope', { status: 500 }) }))); + expect(raw.map(r => r.id)).toEqual(['claude-opus-4-8-high', 'gpt-5.5-high']); + expect(raw.every(r => r.wireModelId === undefined && r.reasoning === undefined)).toBe(true); + }); +}); diff --git a/packages/provider-cursor/src/pricing.ts b/packages/provider-cursor/src/pricing.ts new file mode 100644 index 000000000..43007d93f --- /dev/null +++ b/packages/provider-cursor/src/pricing.ts @@ -0,0 +1,105 @@ +// Per-model notional pricing for the Cursor (subscription) provider. Cursor +// bills as a flat-fee subscription, but the gateway tracks usage cost as if +// each request were billed at Cursor's own usage-based (API-pool) rates — so +// the dashboard can surface "value consumed vs. flat fee". Like every other +// provider (copilot / codex / claude-code / custom / azure / ollama), cost is +// a static per-token table applied to the request's real token counts; nothing +// is fetched from the upstream. Values are USD per million tokens. +// +// Source of truth: Cursor's published model pricing (the API-pool rates), +// https://cursor.com/docs/models-and-pricing. The table keys the model ids +// Cursor returns (which the provider records verbatim as `modelKey = model.id`, +// e.g. `claude-opus-4-8`, `gpt-5.5`, `composer-2.5`). New ids the upstream +// rolls out should be added here. Refresh procedure: +// .agents/skills/fetching-models-pricing/. + +import type { ModelPricing } from '@floway-dev/protocols/common'; + +// Anthropic families carry a cache-write price; the 4.5+ generation shares one +// rate card per tier (Opus 4.5–4.8 = $5/$25, every Sonnet = $3/$15). +const OPUS_PRICING: ModelPricing = { input: 5, input_cache_read: 0.5, input_cache_write: 6.25, output: 25 }; +const SONNET_PRICING: ModelPricing = { input: 3, input_cache_read: 0.3, input_cache_write: 3.75, output: 15 }; +const HAIKU_PRICING: ModelPricing = { input: 1, input_cache_read: 0.1, input_cache_write: 1.25, output: 5 }; +const FABLE_PRICING: ModelPricing = { input: 10, input_cache_read: 1, input_cache_write: 12.5, output: 50 }; + +// OpenAI / composer / others: Cursor lists no cache-write rate (N/A). +const GPT_5_5_PRICING: ModelPricing = { input: 5, input_cache_read: 0.5, output: 30 }; +const GPT_5_4_PRICING: ModelPricing = { input: 2.5, input_cache_read: 0.25, output: 15 }; +const GPT_5_4_MINI_PRICING: ModelPricing = { input: 0.75, input_cache_read: 0.075, output: 4.5 }; +const GPT_5_4_NANO_PRICING: ModelPricing = { input: 0.2, input_cache_read: 0.02, output: 1.25 }; +const GPT_5_2_3_PRICING: ModelPricing = { input: 1.75, input_cache_read: 0.175, output: 14 }; +const GPT_5_1_PRICING: ModelPricing = { input: 1.25, input_cache_read: 0.125, output: 10 }; +const GPT_5_MINI_PRICING: ModelPricing = { input: 0.25, input_cache_read: 0.025, output: 2 }; + +const COMPOSER_2_PRICING: ModelPricing = { input: 0.5, input_cache_read: 0.2, output: 2.5 }; +const COMPOSER_1_5_PRICING: ModelPricing = { input: 3.5, input_cache_read: 0.35, output: 17.5 }; +const COMPOSER_1_PRICING: ModelPricing = { input: 1.25, input_cache_read: 0.125, output: 10 }; + +const GEMINI_3_PRO_PRICING: ModelPricing = { input: 2, input_cache_read: 0.2, output: 12 }; +const GEMINI_3_FLASH_PRICING: ModelPricing = { input: 0.5, input_cache_read: 0.05, output: 3 }; +const GEMINI_3_5_FLASH_PRICING: ModelPricing = { input: 1.5, input_cache_read: 0.15, output: 9 }; +const GEMINI_2_5_FLASH_PRICING: ModelPricing = { input: 0.3, input_cache_read: 0.03, output: 2.5 }; + +const GLM_5_2_PRICING: ModelPricing = { input: 1.4, input_cache_read: 0.26, output: 4.4 }; +const GROK_4_3_PRICING: ModelPricing = { input: 1.25, input_cache_read: 0.2, output: 2.5 }; +const GROK_4_20_PRICING: ModelPricing = { input: 2, input_cache_read: 0.2, output: 6 }; +const GROK_BUILD_PRICING: ModelPricing = { input: 1, input_cache_read: 0.2, output: 2 }; +const KIMI_K2_5_PRICING: ModelPricing = { input: 0.6, input_cache_read: 0.1, output: 3 }; + +// The "Auto" routing pool has its own blended rate. +const AUTO_PRICING: ModelPricing = { input: 1.25, input_cache_read: 0.25, output: 6 }; + +// Ordered: a more-specific variant (mini / nano / codex-mini / a dotted +// version) must precede the family base so it wins the match. Separators vary +// (`gpt-5.4-mini`, `claude-opus-4-8`), so `[._-]?` bridges dot vs hyphen. +const CURSOR_MODEL_PRICING: readonly (readonly [key: string | RegExp, pricing: ModelPricing])[] = [ + ['auto', AUTO_PRICING], + + // Anthropic — every Opus 4.5+ shares a rate, as does every Sonnet. + [/^claude-opus/i, OPUS_PRICING], + [/^claude-sonnet/i, SONNET_PRICING], + [/^claude-haiku/i, HAIKU_PRICING], + [/^claude-fable/i, FABLE_PRICING], + + // OpenAI GPT-5 line. Nano/mini before the base; dotted versions before + // families; codex-mini before the rest of the 5.1 line. + [/^gpt-5[._-]?5/i, GPT_5_5_PRICING], + [/^gpt-5[._-]?4-nano/i, GPT_5_4_NANO_PRICING], + [/^gpt-5[._-]?4-mini/i, GPT_5_4_MINI_PRICING], + [/^gpt-5[._-]?4/i, GPT_5_4_PRICING], + [/^gpt-5[._-]?3/i, GPT_5_2_3_PRICING], + [/^gpt-5[._-]?2/i, GPT_5_2_3_PRICING], + [/^gpt-5[._-]?1-codex-mini/i, GPT_5_MINI_PRICING], + [/^gpt-5[._-]?1/i, GPT_5_1_PRICING], + [/^gpt-5-mini/i, GPT_5_MINI_PRICING], + [/^gpt-5-codex/i, GPT_5_1_PRICING], + [/^gpt-5$/i, GPT_5_1_PRICING], + + // Cursor's own composer family. + [/^composer-1[._-]?5/i, COMPOSER_1_5_PRICING], + [/^composer-1/i, COMPOSER_1_PRICING], + [/^composer/i, COMPOSER_2_PRICING], + + // Google Gemini. + [/^gemini-3[._-]?5-flash/i, GEMINI_3_5_FLASH_PRICING], + [/^gemini-3[._-]?1-pro/i, GEMINI_3_PRO_PRICING], + [/^gemini-3-pro/i, GEMINI_3_PRO_PRICING], + [/^gemini-3-flash/i, GEMINI_3_FLASH_PRICING], + [/^gemini-2[._-]?5-flash/i, GEMINI_2_5_FLASH_PRICING], + + // Other vendors Cursor exposes. + [/^glm-5[._-]?2/i, GLM_5_2_PRICING], + [/^grok-4[._-]?20/i, GROK_4_20_PRICING], + [/^grok-4[._-]?3/i, GROK_4_3_PRICING], + [/^grok-build/i, GROK_BUILD_PRICING], + [/^kimi/i, KIMI_K2_5_PRICING], +]; + +export const pricingForCursorModelKey = (modelKey: string): ModelPricing | null => { + for (const [key, pricing] of CURSOR_MODEL_PRICING) { + if (typeof key === 'string' ? modelKey === key : key.test(modelKey)) { + return pricing; + } + } + return null; +}; diff --git a/packages/provider-cursor/src/pricing_test.ts b/packages/provider-cursor/src/pricing_test.ts new file mode 100644 index 000000000..db8950090 --- /dev/null +++ b/packages/provider-cursor/src/pricing_test.ts @@ -0,0 +1,38 @@ +import { describe, expect, test } from 'vitest'; + +import { pricingForCursorModelKey } from './pricing.ts'; + +describe('pricingForCursorModelKey', () => { + test('prices the current Claude lineup by family (Cursor API-pool rates)', () => { + // Every Opus 4.5+ shares $5 in / $25 out, every Sonnet $3 / $15. + expect(pricingForCursorModelKey('claude-opus-4-8')).toMatchObject({ input: 5, input_cache_read: 0.5, input_cache_write: 6.25, output: 25 }); + expect(pricingForCursorModelKey('claude-opus-4-5')?.output).toBe(25); + expect(pricingForCursorModelKey('claude-sonnet-5')).toMatchObject({ input: 3, input_cache_write: 3.75, output: 15 }); + expect(pricingForCursorModelKey('claude-sonnet-4-6')?.input).toBe(3); + expect(pricingForCursorModelKey('claude-fable-5')?.output).toBe(50); + }); + + test('prices composer-2.5 with Cursor\'s own composer rate', () => { + expect(pricingForCursorModelKey('composer-2.5')).toMatchObject({ input: 0.5, input_cache_read: 0.2, output: 2.5 }); + }); + + test('resolves GPT-5 variants to their distinct rates, specific before base', () => { + expect(pricingForCursorModelKey('gpt-5.5')?.output).toBe(30); + expect(pricingForCursorModelKey('gpt-5.4')?.input).toBe(2.5); + expect(pricingForCursorModelKey('gpt-5.4-mini')?.input).toBe(0.75); + expect(pricingForCursorModelKey('gpt-5.4-nano')?.input).toBe(0.2); + expect(pricingForCursorModelKey('gpt-5.3-codex')?.input).toBe(1.75); + expect(pricingForCursorModelKey('gpt-5.2')?.output).toBe(14); + expect(pricingForCursorModelKey('gpt-5.1-codex-max')?.input).toBe(1.25); + expect(pricingForCursorModelKey('gpt-5.1-codex-mini')?.input).toBe(0.25); + expect(pricingForCursorModelKey('gpt-5-mini')?.input).toBe(0.25); + }); + + test('prices the Auto pool', () => { + expect(pricingForCursorModelKey('auto')).toMatchObject({ input: 1.25, input_cache_read: 0.25, output: 6 }); + }); + + test('returns null for an unknown model', () => { + expect(pricingForCursorModelKey('some-unknown-model')).toBeNull(); + }); +}); diff --git a/packages/provider-cursor/src/proto/agent-messages.ts b/packages/provider-cursor/src/proto/agent-messages.ts new file mode 100644 index 000000000..0357ceea6 --- /dev/null +++ b/packages/provider-cursor/src/proto/agent-messages.ts @@ -0,0 +1,238 @@ +import { + encodeStringField, + encodeInt32Field, + encodeMessageField, + encodeBoolField, + concatBytes, + encodeProtobufValue, +} from './encoding.ts'; +import { AgentMode } from './types.ts'; +import type { OpenAIToolDefinition } from './types.ts'; + +/** + * AgentClientMessage + AgentRunRequest encoding. + * + * Workers-clean: environment facts (os/shell/cwd/timezone) are passed in via + * RequestContextEnv — no process.cwd() / node:os / process.env reads here. + */ + +const MCP_PROVIDER = 'cursor-tools'; + +export interface RequestContextEnv { + workspacePath: string; + osVersion: string; + shell: string; + timezone: string; +} + +export function encodeMcpToolDefinition(tool: OpenAIToolDefinition, providerIdentifier = MCP_PROVIDER): Uint8Array { + const toolName = tool.function.name; + const combinedName = `${providerIdentifier}-${toolName}`; + const description = tool.function.description ?? ''; + const inputSchema = tool.function.parameters ?? { type: 'object', properties: {} }; + + const parts: Uint8Array[] = [ + encodeStringField(1, combinedName), + encodeStringField(2, description), + ]; + + if (inputSchema) { + const schemaValue = encodeProtobufValue(inputSchema); + parts.push(encodeMessageField(3, schemaValue)); + } + + parts.push(encodeStringField(4, providerIdentifier)); + parts.push(encodeStringField(5, toolName)); + + return concatBytes(...parts); +} + +export function buildRequestContextEnv(env: RequestContextEnv): Uint8Array { + return concatBytes( + encodeStringField(1, env.osVersion), + encodeStringField(2, env.workspacePath), + encodeStringField(3, env.shell), + encodeStringField(10, env.timezone), + encodeStringField(11, env.workspacePath), + ); +} + +export function encodeMcpInstructions(serverName: string, instructions: string): Uint8Array { + return concatBytes(encodeStringField(1, serverName), encodeStringField(2, instructions)); +} + +export function buildRequestContext(env: RequestContextEnv, tools?: OpenAIToolDefinition[]): Uint8Array { + const parts: Uint8Array[] = []; + + const envBytes = buildRequestContextEnv(env); + parts.push(encodeMessageField(4, envBytes)); + + if (tools && tools.length > 0) { + for (const tool of tools) { + const mcpTool = encodeMcpToolDefinition(tool, MCP_PROVIDER); + parts.push(encodeMessageField(7, mcpTool)); + } + + const toolDescriptions = tools + .map(t => `- ${t.function.name}: ${t.function.description ?? 'No description'}`) + .join('\n'); + const instructions = `You have access to the following tools:\n${toolDescriptions}\n\nUse these tools when appropriate to help the user.`; + + const mcpInstr = encodeMcpInstructions(MCP_PROVIDER, instructions); + parts.push(encodeMessageField(14, mcpInstr)); + } + + return concatBytes(...parts); +} + +export interface SelectedImageInput { + data: Uint8Array; + mimeType: string; +} + +// SelectedImage (agent.v1): inline image bytes. uuid=2, mime_type=7, and the +// raw bytes ride the `data_or_blob_id` oneof at data=8 (length-delimited, same +// wire form as an embedded message). dimension is optional and omitted. +export function encodeSelectedImage(image: SelectedImageInput): Uint8Array { + return concatBytes( + encodeStringField(2, crypto.randomUUID()), + encodeStringField(7, image.mimeType), + encodeMessageField(8, image.data), + ); +} + +// SelectedContext: selected_images is the repeated field 1. +export function encodeSelectedContext(images: readonly SelectedImageInput[]): Uint8Array { + return concatBytes(...images.map(img => encodeMessageField(1, encodeSelectedImage(img)))); +} + +export function encodeUserMessage(text: string, messageId: string, mode: AgentMode = AgentMode.ASK, images?: readonly SelectedImageInput[]): Uint8Array { + const parts: Uint8Array[] = [encodeStringField(1, text), encodeStringField(2, messageId)]; + // UserMessage.selected_context = 3 (before mode to keep ascending field order). + if (images && images.length > 0) parts.push(encodeMessageField(3, encodeSelectedContext(images))); + parts.push(encodeInt32Field(4, mode)); + return concatBytes(...parts); +} + +export function encodeUserMessageAction(userMessage: Uint8Array, requestContext: Uint8Array): Uint8Array { + return concatBytes(encodeMessageField(1, userMessage), encodeMessageField(2, requestContext)); +} + +export function encodeConversationAction(userMessageAction: Uint8Array): Uint8Array { + return encodeMessageField(1, userMessageAction); +} + +export function encodeModelDetails(modelId: string, maxMode?: boolean): Uint8Array { + const parts: Uint8Array[] = [encodeStringField(1, modelId)]; + // ModelDetails.max_mode (field 7): opt into Cursor's Max Mode — the larger + // context window / higher-cost tier. Only emitted when enabled so the default + // wire shape is unchanged. + if (maxMode) parts.push(encodeBoolField(7, true)); + return concatBytes(...parts); +} + +export function encodeMcpTools(tools: OpenAIToolDefinition[]): Uint8Array { + const parts: Uint8Array[] = []; + for (const tool of tools) { + const mcpTool = encodeMcpToolDefinition(tool, MCP_PROVIDER); + parts.push(encodeMessageField(1, mcpTool)); + } + return concatBytes(...parts); +} + +export function encodeMcpDescriptor( + serverName: string, + serverIdentifier: string, + folderPath?: string, + serverUseInstructions?: string, +): Uint8Array { + const parts: Uint8Array[] = [encodeStringField(1, serverName), encodeStringField(2, serverIdentifier)]; + + if (folderPath) { + parts.push(encodeStringField(3, folderPath)); + } + + if (serverUseInstructions) { + parts.push(encodeStringField(4, serverUseInstructions)); + } + + return concatBytes(...parts); +} + +export interface McpDescriptorInput { + serverName: string; + serverIdentifier: string; + folderPath?: string; + serverUseInstructions?: string; +} + +export function encodeMcpFileSystemOptions( + enabled: boolean, + workspaceProjectDir: string, + mcpDescriptors: McpDescriptorInput[], +): Uint8Array { + const parts: Uint8Array[] = []; + + if (enabled) { + parts.push(encodeBoolField(1, true)); + } + + if (workspaceProjectDir) { + parts.push(encodeStringField(2, workspaceProjectDir)); + } + + for (const descriptor of mcpDescriptors) { + const encodedDescriptor = encodeMcpDescriptor( + descriptor.serverName, + descriptor.serverIdentifier, + descriptor.folderPath, + descriptor.serverUseInstructions, + ); + parts.push(encodeMessageField(3, encodedDescriptor)); + } + + return concatBytes(...parts); +} + +export function encodeAgentRunRequest( + action: Uint8Array, + modelDetails: Uint8Array, + conversationId: string | undefined, + tools: OpenAIToolDefinition[] | undefined, + workspacePath: string | undefined, +): Uint8Array { + // ConversationState: empty message on the wire. + const parts: Uint8Array[] = [ + encodeMessageField(1, new Uint8Array(0)), + encodeMessageField(2, action), + encodeMessageField(3, modelDetails), + ]; + + if (tools && tools.length > 0) { + const mcpToolsWrapper = encodeMcpTools(tools); + parts.push(encodeMessageField(4, mcpToolsWrapper)); + } + + if (conversationId) { + parts.push(encodeStringField(5, conversationId)); + } + + if (tools && tools.length > 0 && workspacePath) { + const mcpDescriptors: McpDescriptorInput[] = [ + { + serverName: 'Cursor Tools', + serverIdentifier: MCP_PROVIDER, + folderPath: workspacePath, + serverUseInstructions: 'Use these tools to assist the user with their coding tasks.', + }, + ]; + const mcpFsOptions = encodeMcpFileSystemOptions(true, workspacePath, mcpDescriptors); + parts.push(encodeMessageField(6, mcpFsOptions)); + } + + return concatBytes(...parts); +} + +export function encodeAgentClientMessage(runRequest: Uint8Array): Uint8Array { + return encodeMessageField(1, runRequest); +} diff --git a/packages/provider-cursor/src/proto/bidi.ts b/packages/provider-cursor/src/proto/bidi.ts new file mode 100644 index 000000000..e44fd297e --- /dev/null +++ b/packages/provider-cursor/src/proto/bidi.ts @@ -0,0 +1,31 @@ +/** + * BidiSse Protocol Encoding + * + * Encoding of the two HTTP/1.1 control messages that pair the RunSSE read + * channel with the BidiAppend write channel. + */ + +import { encodeStringField, encodeMessageField, encodeInt64Field, concatBytes } from './encoding.ts'; + +/** + * Encode BidiRequestId — the body of the RunSSE POST. + * - request_id: field 1 (string) + */ +export function encodeBidiRequestId(requestId: string): Uint8Array { + return encodeStringField(1, requestId); +} + +/** + * Encode BidiAppendRequest — the body of each BidiAppend POST. + * - data: field 1 (string, hex-encoded AgentClientMessage; empty for heartbeat) + * - request_id: field 2 (BidiRequestId message) + * - append_seqno: field 3 (int64, monotonic per request) + */ +export function encodeBidiAppendRequest(data: string, requestId: string, appendSeqno: bigint): Uint8Array { + const requestIdMsg = encodeBidiRequestId(requestId); + return concatBytes( + encodeStringField(1, data), + encodeMessageField(2, requestIdMsg), + encodeInt64Field(3, appendSeqno), + ); +} diff --git a/packages/provider-cursor/src/proto/decoding.ts b/packages/provider-cursor/src/proto/decoding.ts new file mode 100644 index 000000000..b2b680d62 --- /dev/null +++ b/packages/provider-cursor/src/proto/decoding.ts @@ -0,0 +1,152 @@ +/** + * Protobuf Decoding Helpers + * + * Low-level utilities for decoding protobuf wire format: varint decoding, + * field parsing, and google.protobuf.Value decoding. Pure Uint8Array + + * DataView — no Buffer. + */ + +// Shared TextDecoder — reused across every string decode on the incoming path +// (text_delta frames, tool call ids/args, KV blob keys, etc.). TextDecoder has +// no cross-call state for defaults ({ fatal: false, ignoreBOM: false }), so +// reuse is safe. +export const TEXT_DECODER = /*@__PURE__*/ new TextDecoder(); + +// Shared fatal-mode decoder for the KV blob "is this UTF-8 text?" probe path. +// Separate from TEXT_DECODER because the fatal option changes decode behavior. +export const TEXT_DECODER_FATAL = /*@__PURE__*/ new TextDecoder('utf-8', { fatal: true }); + +export interface ParsedField { + fieldNumber: number; + wireType: number; + value: Uint8Array | number | bigint; +} + +export function decodeVarint(data: Uint8Array, offset: number): { value: number; bytesRead: number } { + let value = 0; + let shift = 0; + let bytesRead = 0; + + while (offset + bytesRead < data.length) { + const byte = data[offset + bytesRead]; + if (byte === undefined) break; + value |= (byte & 0x7f) << shift; + bytesRead++; + + if ((byte & 0x80) === 0) { + break; + } + shift += 7; + } + + return { value, bytesRead }; +} + +export function parseProtoFields(data: Uint8Array): ParsedField[] { + const fields: ParsedField[] = []; + let offset = 0; + + while (offset < data.length) { + const tagInfo = decodeVarint(data, offset); + offset += tagInfo.bytesRead; + + const fieldNumber = tagInfo.value >> 3; + const wireType = tagInfo.value & 0x7; + + if (wireType === 2) { + const lengthInfo = decodeVarint(data, offset); + offset += lengthInfo.bytesRead; + // subarray, not slice: parseProtoFields is called on owned frame + // payloads that outlive the fields we hand back, and no caller mutates + // the input in place — the copy was pure overhead. + const value = data.subarray(offset, offset + lengthInfo.value); + offset += lengthInfo.value; + fields.push({ fieldNumber, wireType, value }); + } else if (wireType === 0) { + const valueInfo = decodeVarint(data, offset); + offset += valueInfo.bytesRead; + fields.push({ fieldNumber, wireType, value: valueInfo.value }); + } else if (wireType === 1) { + const value = data.subarray(offset, offset + 8); + offset += 8; + fields.push({ fieldNumber, wireType, value }); + } else if (wireType === 5) { + const value = data.subarray(offset, offset + 4); + offset += 4; + fields.push({ fieldNumber, wireType, value }); + } else { + break; + } + } + + return fields; +} + +export function parseProtobufValue(data: Uint8Array): unknown { + const fields = parseProtoFields(data); + + for (const field of fields) { + if (field.fieldNumber === 1 && field.wireType === 0) { + return null; + } + if (field.fieldNumber === 2 && field.wireType === 1 && field.value instanceof Uint8Array) { + const view = new DataView(field.value.buffer, field.value.byteOffset, 8); + return view.getFloat64(0, true); + } + if (field.fieldNumber === 3 && field.wireType === 2 && field.value instanceof Uint8Array) { + return TEXT_DECODER.decode(field.value); + } + if (field.fieldNumber === 4 && field.wireType === 0) { + return field.value === 1; + } + if (field.fieldNumber === 5 && field.wireType === 2 && field.value instanceof Uint8Array) { + return parseProtobufStruct(field.value); + } + if (field.fieldNumber === 6 && field.wireType === 2 && field.value instanceof Uint8Array) { + return parseProtobufListValue(field.value); + } + } + + return undefined; +} + +export function parseProtobufStruct(data: Uint8Array): Record { + const fields = parseProtoFields(data); + const result: Record = {}; + + for (const field of fields) { + if (field.fieldNumber === 1 && field.wireType === 2 && field.value instanceof Uint8Array) { + const entryFields = parseProtoFields(field.value); + let key = ''; + let value: unknown = undefined; + + for (const ef of entryFields) { + if (ef.fieldNumber === 1 && ef.wireType === 2 && ef.value instanceof Uint8Array) { + key = TEXT_DECODER.decode(ef.value); + } + if (ef.fieldNumber === 2 && ef.wireType === 2 && ef.value instanceof Uint8Array) { + value = parseProtobufValue(ef.value); + } + } + + if (key) { + result[key] = value; + } + } + } + + return result; +} + +export function parseProtobufListValue(data: Uint8Array): unknown[] { + const fields = parseProtoFields(data); + const result: unknown[] = []; + + for (const field of fields) { + if (field.fieldNumber === 1 && field.wireType === 2 && field.value instanceof Uint8Array) { + result.push(parseProtobufValue(field.value)); + } + } + + return result; +} diff --git a/packages/provider-cursor/src/proto/encoding.ts b/packages/provider-cursor/src/proto/encoding.ts new file mode 100644 index 000000000..f08d22529 --- /dev/null +++ b/packages/provider-cursor/src/proto/encoding.ts @@ -0,0 +1,231 @@ +/** + * Protobuf Encoding Helpers + * + * Low-level utilities for encoding protobuf wire format: + * - Varint encoding (wire type 0) + * - Length-delimited encoding (wire type 2) + * - Fixed-width encoding (wire types 1, 5) + * - google.protobuf.Value encoding for dynamic JSON-like data + * + * Workers-clean: pure Uint8Array + DataView, no Buffer. + */ + +// Shared TextEncoder — reused across all string encodes on the outgoing path +// (tool defs, MCP schemas, ExecMcpResult bodies). TextEncoder has no internal +// buffered state that survives a call, so reuse is safe. +export const TEXT_ENCODER = /*@__PURE__*/ new TextEncoder(); + +// --- Basic Varint and Field Encoding --- + +/** + * Encode a varint (variable-length integer) for protobuf. + * Supports both number and bigint for large values (e.g. append_seqno). + */ +export function encodeVarint(value: number | bigint): Uint8Array { + // A 64-bit varint fits in at most 10 bytes; write into a fixed buffer and + // return an owned slice so the caller can hold onto it independently. + const scratch = new Uint8Array(10); + let len = 0; + let v = BigInt(value); + if (v < 0n) { + // Negative ints encode as two's-complement 64-bit varints (10 bytes). We + // never emit negatives on the Cursor wire, but keep this defensive so a + // stray negative doesn't loop forever. + v = v + (1n << 64n); + } + while (v > 127n) { + scratch[len++] = Number(v & 0x7fn) | 0x80; + v >>= 7n; + } + scratch[len++] = Number(v); + return scratch.slice(0, len); +} + +/** + * Encode a field tag `(field_number << 3) | wire_type` as a varint. Field + * numbers ≥ 16 need a multi-byte tag, so this must never be truncated to a + * single byte. + */ +export function encodeFieldTag(fieldNumber: number, wireType: number): Uint8Array { + return encodeVarint((fieldNumber << 3) | wireType); +} + +/** + * Encode a string field in protobuf format. + * Field format: (field_number << 3) | wire_type; string wire type = 2. + */ +export function encodeStringField(fieldNumber: number, value: string): Uint8Array { + if (!value) return new Uint8Array(0); + + const tag = encodeFieldTag(fieldNumber, 2); + const encoded = TEXT_ENCODER.encode(value); + const length = encodeVarint(encoded.length); + + const result = new Uint8Array(tag.length + length.length + encoded.length); + result.set(tag, 0); + result.set(length, tag.length); + result.set(encoded, tag.length + length.length); + + return result; +} + +/** + * Encode a uint32 field (varint, wire type 0). Omitted when zero — Cursor's + * proto uses default-zero semantics, so a zero field tag is round-trip + * equivalent and keeps frames minimal. + */ +export function encodeUint32Field(fieldNumber: number, value: number): Uint8Array { + if (value === 0) return new Uint8Array(0); + return concatBytes(encodeFieldTag(fieldNumber, 0), encodeVarint(value)); +} + +/** + * Encode an int32 field (varint, wire type 0). Omitted when zero. + */ +export function encodeInt32Field(fieldNumber: number, value: number): Uint8Array { + if (value === 0) return new Uint8Array(0); + return concatBytes(encodeFieldTag(fieldNumber, 0), encodeVarint(value)); +} + +/** + * Encode an int64 field (varint, wire type 0). Always emitted — a zero + * seqno is meaningful on the Cursor wire. + */ +export function encodeInt64Field(fieldNumber: number, value: bigint): Uint8Array { + return concatBytes(encodeFieldTag(fieldNumber, 0), encodeVarint(value)); +} + +/** + * Encode a nested message field (length-delimited, wire type 2). + */ +export function encodeMessageField(fieldNumber: number, data: Uint8Array): Uint8Array { + return concatBytes(encodeFieldTag(fieldNumber, 2), encodeVarint(data.length), data); +} + +/** + * Encode a bool field (varint, wire type 0). + */ +export function encodeBoolField(fieldNumber: number, value: boolean): Uint8Array { + return concatBytes(encodeFieldTag(fieldNumber, 0), new Uint8Array([value ? 1 : 0])); +} + +/** + * Encode a double field (64-bit, wire type 1, little-endian). + */ +export function encodeDoubleField(fieldNumber: number, value: number): Uint8Array { + const bytes = new Uint8Array(8); + new DataView(bytes.buffer).setFloat64(0, value, true); + return concatBytes(encodeFieldTag(fieldNumber, 1), bytes); +} + +/** + * Concatenate multiple Uint8Arrays into one fresh ArrayBuffer-backed array. + */ +export function concatBytes(...arrays: Uint8Array[]): Uint8Array { + const totalLength = arrays.reduce((sum, arr) => sum + arr.length, 0); + const result = new Uint8Array(totalLength); + let offset = 0; + for (const arr of arrays) { + result.set(arr, offset); + offset += arr.length; + } + return result; +} + +// --- google.protobuf.Value Encoding --- + +/** + * Encode a JavaScript value as google.protobuf.Value. + * + * oneof: + * field 1: null_value (enum NullValue = 0) + * field 2: number_value (double) + * field 3: string_value (string) + * field 4: bool_value (bool) + * field 5: struct_value (Struct) + * field 6: list_value (ListValue) + */ +export function encodeProtobufValue(value: unknown): Uint8Array { + if (value === null || value === undefined) { + // NullValue enum = 0. Emit the field explicitly (tag + 0) rather than via + // encodeUint32Field, which omits zero values — an omitted oneof field + // decodes as "no value" under a presence-keyed parser. Cursor's proto3 + // decoder reads either form as NULL_VALUE. + return new Uint8Array([(1 << 3) | 0, 0]); + } + + if (typeof value === 'number') { + return encodeDoubleField(2, value); + } + + if (typeof value === 'string') { + return encodeStringField(3, value); + } + + if (typeof value === 'boolean') { + return encodeBoolField(4, value); + } + + if (Array.isArray(value)) { + const listBytes: Uint8Array[] = []; + for (const item of value) { + const itemValue = encodeProtobufValue(item); + listBytes.push(encodeMessageField(1, itemValue)); + } + const listValue = concatBytes(...listBytes); + return encodeMessageField(6, listValue); + } + + if (typeof value === 'object') { + const structBytes: Uint8Array[] = []; + for (const [key, val] of Object.entries(value as Record)) { + const keyBytes = encodeStringField(1, key); + const valBytes = encodeMessageField(2, encodeProtobufValue(val)); + const mapEntry = concatBytes(keyBytes, valBytes); + structBytes.push(encodeMessageField(1, mapEntry)); + } + const structValue = concatBytes(...structBytes); + return encodeMessageField(5, structValue); + } + + return encodeStringField(3, String(value)); +} + +// --- Hex helpers (Workers-clean replacement for Buffer.from(...).toString('hex')) --- + +const HEX_DIGITS = '0123456789abcdef'; + +/** Encode a Uint8Array as a lowercase hex string. */ +export function bytesToHex(bytes: Uint8Array): string { + let out = ''; + for (const b of bytes) { + out += HEX_DIGITS[(b >>> 4) & 0xf]! + HEX_DIGITS[b & 0xf]!; + } + return out; +} + +/** Decode a lowercase/uppercase hex string into a Uint8Array. */ +export function hexToBytes(hex: string): Uint8Array { + const clean = hex.trim(); + const len = clean.length; + if (len % 2 !== 0) { + throw new TypeError(`hexToBytes: odd-length hex string (${len})`); + } + const out = new Uint8Array(len / 2); + for (let i = 0; i < out.length; i++) { + const hi = hexDigitValue(clean.charCodeAt(i * 2)); + const lo = hexDigitValue(clean.charCodeAt(i * 2 + 1)); + if (hi < 0 || lo < 0) { + throw new TypeError(`hexToBytes: invalid hex digit at index ${i * 2}`); + } + out[i] = (hi << 4) | lo; + } + return out; +} + +function hexDigitValue(code: number): number { + if (code >= 0x30 && code <= 0x39) return code - 0x30; + if (code >= 0x61 && code <= 0x66) return code - 0x61 + 10; + if (code >= 0x41 && code <= 0x46) return code - 0x41 + 10; + return -1; +} diff --git a/packages/provider-cursor/src/proto/envelope.ts b/packages/provider-cursor/src/proto/envelope.ts new file mode 100644 index 000000000..cac736f46 --- /dev/null +++ b/packages/provider-cursor/src/proto/envelope.ts @@ -0,0 +1,127 @@ +/** + * Connect-RPC envelope framing. + * + * Cursor's RunSSE / BidiAppend use the gRPC-Web / connect framing: a 5-byte + * header (1 flag byte + 4 big-endian uint32 length) prefixing each proto + * payload. RunSSE is a stream of these frames; BidiAppend is a single frame + * request and a (usually empty) single-frame response. + * + * Flag bits: + * 0x01 — payload gzip-compressed + * 0x02 — end-stream error (trailer carries grpc-status/message) + * 0x80 — trailer frame (headers: grpc-status, grpc-message, ...) + * + * Workers-clean: Uint8Array + DataView; gzip via DecompressionStream (available + * on Workers and Node 22+). + */ + +export const FLAG_COMPRESSED = 0x01; +export const FLAG_END_STREAM = 0x02; +export const FLAG_TRAILER = 0x80; + +/** Wrap a proto payload in a 5-byte connect envelope. */ +export function addConnectEnvelope(data: Uint8Array, flags = 0): Uint8Array { + const result = new Uint8Array(5 + data.length); + result[0] = flags; + const length = data.length; + result[1] = (length >>> 24) & 0xff; + result[2] = (length >>> 16) & 0xff; + result[3] = (length >>> 8) & 0xff; + result[4] = length & 0xff; + result.set(data, 5); + return result; +} + +export interface ConnectFrame { + flags: number; + payload: Uint8Array; + nextOffset: number; +} + +/** + * Try to read one connect frame starting at `offset` in `buffer`. Returns null + * when the buffer doesn't yet hold a complete frame — the caller should read + * more bytes and retry from the same offset. + */ +export function readConnectFrame(buffer: Uint8Array, offset: number): ConnectFrame | null { + if (offset + 5 > buffer.length) return null; + + const flags = buffer[offset]!; + const b1 = buffer[offset + 1]!; + const b2 = buffer[offset + 2]!; + const b3 = buffer[offset + 3]!; + const b4 = buffer[offset + 4]!; + // Unsigned 32-bit big-endian length. Use >>> to keep it non-negative. + const length = ((b1 << 24) | (b2 << 16) | (b3 << 8) | b4) >>> 0; + + const frameEnd = offset + 5 + length; + if (frameEnd > buffer.length) return null; + + const payload = buffer.slice(offset + 5, frameEnd); + return { flags, payload, nextOffset: frameEnd }; +} + +/** True if a frame's flag byte marks it as a trailer (end-stream metadata). */ +export function isTrailerFrame(flags: number): boolean { + return (flags & FLAG_TRAILER) !== 0; +} + +/** True if a frame's payload is gzip-compressed. */ +export function isCompressedFrame(flags: number): boolean { + return (flags & FLAG_COMPRESSED) !== 0; +} + +/** Parse a trailer frame's header block into a lowercased key->value map. */ +export function parseTrailerMetadata(trailer: string): Record { + const meta: Record = {}; + + for (const rawLine of trailer.split(/\r?\n/)) { + const line = rawLine.trim(); + if (!line) continue; + const idx = line.indexOf(':'); + if (idx === -1) continue; + + const key = line.slice(0, idx).trim().toLowerCase(); + const value = line.slice(idx + 1).trim(); + if (!key) continue; + meta[key] = value; + } + + return meta; +} + +/** + * Gunzip a payload using the Web Streams DecompressionStream. No-op on empty + * input. Throws if the runtime lacks DecompressionStream. + */ +export async function decompressGzip(payload: Uint8Array): Promise { + if (payload.length === 0) return payload; + const ds = new DecompressionStream('gzip'); + const writer = ds.writable.getWriter(); + // Copy into a fresh ArrayBuffer-backed view so the writable's BufferSource + // constraint is satisfied regardless of the caller's backing storage + // (SharedArrayBuffer-backed views fail the ArrayBuffer-typed buffer check). + void writer.write(new Uint8Array(payload)); + await writer.close(); + + const reader = ds.readable.getReader(); + const chunks: Uint8Array[] = []; + let total = 0; + + while (true) { + const { done, value } = await reader.read(); + if (done) break; + if (value) { + chunks.push(value); + total += value.length; + } + } + + const out = new Uint8Array(total); + let off = 0; + for (const c of chunks) { + out.set(c, off); + off += c.length; + } + return out; +} diff --git a/packages/provider-cursor/src/proto/exec.ts b/packages/provider-cursor/src/proto/exec.ts new file mode 100644 index 000000000..95a008d67 --- /dev/null +++ b/packages/provider-cursor/src/proto/exec.ts @@ -0,0 +1,538 @@ +/** + * ExecServerMessage / ExecClientMessage encoding. + * + * The Cursor backend sends ExecServerMessage (AgentServerMessage field 2) to + * request a tool execution; the client replies with ExecClientMessage + * (AgentClientMessage field 2) carrying the result, then a stream-close + * control message (AgentClientMessage field 5). + * + * Workers-clean: environment facts come via RequestContextEnv — no + * process.cwd() / node:os / process.env reads here. + */ + +import type { RequestContextEnv } from './agent-messages.ts'; +import { TEXT_DECODER, parseProtoFields, parseProtobufValue, type ParsedField } from './decoding.ts'; +import { + encodeStringField, + encodeUint32Field, + encodeInt32Field, + encodeInt64Field, + encodeMessageField, + encodeBoolField, + concatBytes, +} from './encoding.ts'; +import type { + ExecRequest, + McpExecRequest, + ShellExecRequest, + LsExecRequest, + ReadExecRequest, + GrepExecRequest, + WriteExecRequest, + McpResult, + WriteResult, +} from './types.ts'; + +// String helper for length-delimited wire fields. Returns undefined when the +// field is absent — callers coalesce with a default when the proto contract +// treats missing as empty string. +function strField(fields: ParsedField[], num: number): string | undefined { + for (const f of fields) { + if (f.fieldNumber === num && f.wireType === 2 && f.value instanceof Uint8Array) { + return TEXT_DECODER.decode(f.value); + } + } + return undefined; +} + +function parseShellArgs(data: Uint8Array): { command: string; cwd?: string } { + const fields = parseProtoFields(data); + return { command: strField(fields, 1) ?? '', cwd: strField(fields, 2) }; +} + +function parseLsArgs(data: Uint8Array): { path: string } { + return { path: strField(parseProtoFields(data), 1) ?? '' }; +} + +function parseReadArgs(data: Uint8Array): { path: string } { + return { path: strField(parseProtoFields(data), 1) ?? '' }; +} + +function parseGrepArgs(data: Uint8Array): { pattern: string; path?: string; glob?: string } { + const fields = parseProtoFields(data); + return { + pattern: strField(fields, 1) ?? '', + path: strField(fields, 2), + glob: strField(fields, 3), + }; +} + +function parseWriteArgs(data: Uint8Array): { + path: string; + fileText: string; + toolCallId?: string; + returnFileContentAfterWrite?: boolean; + fileBytes?: Uint8Array; +} { + const fields = parseProtoFields(data); + let returnFileContentAfterWrite: boolean | undefined; + let fileBytes: Uint8Array | undefined; + + for (const field of fields) { + if (field.fieldNumber === 4 && field.wireType === 0) { + returnFileContentAfterWrite = field.value === 1; + } else if (field.fieldNumber === 5 && field.wireType === 2 && field.value instanceof Uint8Array) { + fileBytes = field.value; + } + } + + return { + path: strField(fields, 1) ?? '', + fileText: strField(fields, 2) ?? '', + toolCallId: strField(fields, 3), + returnFileContentAfterWrite, + fileBytes, + }; +} + +function parseMcpArgs(data: Uint8Array): Omit { + const fields = parseProtoFields(data); + const args: Record = {}; + + for (const field of fields) { + if (field.fieldNumber === 2 && field.wireType === 2 && field.value instanceof Uint8Array) { + const entryFields = parseProtoFields(field.value); + const key = strField(entryFields, 1); + let value: unknown = undefined; + for (const ef of entryFields) { + if (ef.fieldNumber === 2 && ef.wireType === 2 && ef.value instanceof Uint8Array) { + value = parseProtobufValue(ef.value); + } + } + if (key) args[key] = value; + } + } + + return { + name: strField(fields, 1) ?? '', + args, + toolCallId: strField(fields, 3) ?? '', + providerIdentifier: strField(fields, 4) ?? '', + toolName: strField(fields, 5) ?? '', + }; +} + +/** + * Parse an ExecServerMessage into a typed ExecRequest. + * + * Field 1 = id (uint32), field 15 = exec_id (string). The oneof tool payload + * is carried in a tool-type-specific field (2/14 shell, 3 write, 5 grep, + * 7 read, 8 ls, 10 request_context, 11 mcp). + */ +export function parseExecServerMessage(data: Uint8Array): ExecRequest | null { + const fields = parseProtoFields(data); + let id = 0; + let result: ExecRequest | null = null; + + for (const field of fields) { + if (field.fieldNumber === 1 && field.wireType === 0) { + id = field.value as number; + break; + } + } + const execId = strField(fields, 15); + + for (const field of fields) { + if (field.wireType !== 2 || !(field.value instanceof Uint8Array)) continue; + + switch (field.fieldNumber) { + case 2: + case 14: { + const shellArgs = parseShellArgs(field.value); + result = { type: 'shell', id, execId, command: shellArgs.command, cwd: shellArgs.cwd } as ShellExecRequest; + break; + } + case 3: { + const writeArgs = parseWriteArgs(field.value); + result = { + type: 'write', + id, + execId, + path: writeArgs.path, + fileText: writeArgs.fileText, + toolCallId: writeArgs.toolCallId, + returnFileContentAfterWrite: writeArgs.returnFileContentAfterWrite, + fileBytes: writeArgs.fileBytes, + } as WriteExecRequest; + break; + } + case 5: { + const grepArgs = parseGrepArgs(field.value); + result = { + type: 'grep', + id, + execId, + pattern: grepArgs.pattern, + path: grepArgs.path, + glob: grepArgs.glob, + } as GrepExecRequest; + break; + } + case 7: { + const readArgs = parseReadArgs(field.value); + result = { type: 'read', id, execId, path: readArgs.path } as ReadExecRequest; + break; + } + case 8: { + const lsArgs = parseLsArgs(field.value); + result = { type: 'ls', id, execId, path: lsArgs.path } as LsExecRequest; + break; + } + case 10: { + result = { type: 'request_context', id, execId }; + break; + } + case 11: { + const mcpArgs = parseMcpArgs(field.value); + result = { type: 'mcp', id, execId, ...mcpArgs } as McpExecRequest & { type: 'mcp' }; + break; + } + } + + if (result) break; + } + + return result; +} + +// --- Result encoders --- + +// Every build*Result wraps its inner encoding with the same 3-part envelope: +// id (field 1, uint32), optional exec_id (field 15, string), and the +// tool-specific inner message on a per-tool field number. +function wrapExecClient(id: number, execId: string | undefined, innerField: number, innerBytes: Uint8Array): Uint8Array { + const parts: Uint8Array[] = [encodeUint32Field(1, id)]; + if (execId) parts.push(encodeStringField(15, execId)); + parts.push(encodeMessageField(innerField, innerBytes)); + return concatBytes(...parts); +} + +function encodeMcpTextContent(text: string): Uint8Array { + return encodeStringField(1, text); +} + +function encodeMcpToolResultContentItem(text: string): Uint8Array { + const textContent = encodeMcpTextContent(text); + return encodeMessageField(1, textContent); +} + +function encodeMcpSuccess(content: string, isError = false): Uint8Array { + const parts: Uint8Array[] = []; + const contentItem = encodeMcpToolResultContentItem(content); + parts.push(encodeMessageField(1, contentItem)); + if (isError) { + parts.push(encodeBoolField(2, true)); + } + return concatBytes(...parts); +} + +function encodeMcpError(error: string): Uint8Array { + return encodeStringField(1, error); +} + +function encodeMcpResult(result: McpResult): Uint8Array { + if (result.success) { + const success = encodeMcpSuccess(result.success.content, result.success.isError); + return encodeMessageField(1, success); + } + if (result.error) { + const error = encodeMcpError(result.error); + return encodeMessageField(2, error); + } + return encodeMessageField(1, encodeMcpSuccess('')); +} + +export function buildExecClientMessageWithMcpResult( + id: number, + execId: string | undefined, + result: McpResult, +): Uint8Array { + return wrapExecClient(id, execId, 11, encodeMcpResult(result)); +} + +function encodeShellResult( + command: string, + cwd: string, + stdout: string, + stderr: string, + exitCode: number, + executionTimeMs?: number, +): Uint8Array { + const shellOutcome = concatBytes( + encodeStringField(1, command), + encodeStringField(2, cwd), + encodeInt32Field(3, exitCode), + encodeStringField(4, ''), + encodeStringField(5, stdout), + encodeStringField(6, stderr), + executionTimeMs ? encodeInt32Field(7, executionTimeMs) : new Uint8Array(0), + ); + const resultField = exitCode === 0 ? 1 : 2; + return encodeMessageField(resultField, shellOutcome); +} + +function buildExecClientMessageWithShellResult( + id: number, + execId: string | undefined, + command: string, + cwd: string, + stdout: string, + stderr: string, + exitCode: number, + executionTimeMs?: number, +): Uint8Array { + return wrapExecClient(id, execId, 2, encodeShellResult(command, cwd, stdout, stderr, exitCode, executionTimeMs)); +} + +function encodeLsResult(filesString: string): Uint8Array { + const lsSuccess = encodeStringField(1, filesString); + return encodeMessageField(1, lsSuccess); +} + +function buildExecClientMessageWithLsResult( + id: number, + execId: string | undefined, + filesString: string, +): Uint8Array { + return wrapExecClient(id, execId, 8, encodeLsResult(filesString)); +} + +function encodeRequestContextResult(env: RequestContextEnv): Uint8Array { + const envBytes = concatBytes( + encodeStringField(1, env.osVersion), + encodeStringField(2, env.workspacePath), + encodeStringField(3, env.shell), + encodeStringField(10, env.timezone), + encodeStringField(11, env.workspacePath), + ); + + const requestContext = encodeMessageField(4, envBytes); + const success = encodeMessageField(1, requestContext); + return encodeMessageField(1, success); +} + +export function buildExecClientMessageWithRequestContextResult( + id: number, + execId: string | undefined, + env: RequestContextEnv, +): Uint8Array { + return wrapExecClient(id, execId, 10, encodeRequestContextResult(env)); +} + +function encodeReadResult( + content: string, + path: string, + totalLines?: number, + fileSize?: bigint, + truncated?: boolean, +): Uint8Array { + const parts: Uint8Array[] = []; + parts.push(encodeStringField(1, path)); + parts.push(encodeStringField(2, content)); + if (totalLines !== undefined && totalLines > 0) { + parts.push(encodeInt32Field(3, totalLines)); + } + if (fileSize !== undefined) { + parts.push(encodeInt64Field(4, fileSize)); + } + if (truncated) { + parts.push(encodeBoolField(6, true)); + } + const readSuccess = concatBytes(...parts); + return encodeMessageField(1, readSuccess); +} + +function buildExecClientMessageWithReadResult( + id: number, + execId: string | undefined, + content: string, + path: string, + totalLines?: number, + fileSize?: bigint, + truncated?: boolean, +): Uint8Array { + return wrapExecClient(id, execId, 7, encodeReadResult(content, path, totalLines, fileSize, truncated)); +} + +function encodeGrepFilesResult(files: string[], totalFiles: number, truncated = false): Uint8Array { + const parts: Uint8Array[] = []; + for (const file of files) { + parts.push(encodeStringField(1, file)); + } + parts.push(encodeInt32Field(2, totalFiles)); + if (truncated) { + parts.push(encodeBoolField(3, true)); + } + return concatBytes(...parts); +} + +function encodeGrepUnionResult(files: string[], totalFiles: number, truncated = false): Uint8Array { + const filesResult = encodeGrepFilesResult(files, totalFiles, truncated); + return encodeMessageField(2, filesResult); +} + +function encodeGrepSuccess(pattern: string, path: string, files: string[]): Uint8Array { + const parts: Uint8Array[] = []; + parts.push(encodeStringField(1, pattern)); + parts.push(encodeStringField(2, path)); + parts.push(encodeStringField(3, 'files_with_matches')); + const unionResult = encodeGrepUnionResult(files, files.length); + const mapEntry = concatBytes(encodeStringField(1, path), encodeMessageField(2, unionResult)); + parts.push(encodeMessageField(4, mapEntry)); + return concatBytes(...parts); +} + +function encodeGrepResult(pattern: string, path: string, files: string[]): Uint8Array { + const success = encodeGrepSuccess(pattern, path, files); + return encodeMessageField(1, success); +} + +function buildExecClientMessageWithGrepResult( + id: number, + execId: string | undefined, + pattern: string, + path: string, + files: string[], +): Uint8Array { + return wrapExecClient(id, execId, 5, encodeGrepResult(pattern, path, files)); +} + +function encodeWriteSuccess( + path: string, + linesCreated: number, + fileSize: number, + fileContentAfterWrite?: string, +): Uint8Array { + const parts: Uint8Array[] = []; + parts.push(encodeStringField(1, path)); + parts.push(encodeInt32Field(2, linesCreated)); + parts.push(encodeInt32Field(3, fileSize)); + if (fileContentAfterWrite) { + parts.push(encodeStringField(4, fileContentAfterWrite)); + } + return concatBytes(...parts); +} + +function encodeWriteError(path: string, error: string): Uint8Array { + return concatBytes(encodeStringField(1, path), encodeStringField(2, error)); +} + +function encodeWriteResult(result: WriteResult): Uint8Array { + if (result.success) { + const success = encodeWriteSuccess( + result.success.path, + result.success.linesCreated, + result.success.fileSize, + result.success.fileContentAfterWrite, + ); + return encodeMessageField(1, success); + } + if (result.error) { + const error = encodeWriteError(result.error.path, result.error.error); + return encodeMessageField(5, error); + } + return encodeMessageField(1, encodeWriteSuccess('', 0, 0)); +} + +function buildExecClientMessageWithWriteResult( + id: number, + execId: string | undefined, + result: WriteResult, +): Uint8Array { + return wrapExecClient(id, execId, 3, encodeWriteResult(result)); +} + +// --- Rejected-tool encoders --- +// +// Floway is a stateless gateway: it cannot execute Cursor's built-in agent +// tools (shell/read/write/grep/ls). When the backend requests one, we reply +// with a result that signals "unavailable" so the model can adapt and keep +// streaming, then close the exec stream. request_context is environmental +// metadata, not a tool — it gets a real (placeholder-env) response upstream, +// not a rejection. + +/** + * Build an ExecClientMessage that rejects a built-in tool request with the + * given reason. `mcp` and `request_context` are not rejected here — mcp is + * translated to downstream tool_calls (no reply sent on this channel), and + * request_context is answered with the gateway env. + */ +export function buildExecClientMessageWithRejectedTool( + execRequest: ExecRequest, + reason: string, +): Uint8Array { + switch (execRequest.type) { + case 'shell': + return buildExecClientMessageWithShellResult( + execRequest.id, + execRequest.execId, + execRequest.command, + execRequest.cwd ?? '', + '', + reason, + 1, + ); + case 'read': + return buildExecClientMessageWithReadResult( + execRequest.id, + execRequest.execId, + `[tool unavailable: ${reason}]`, + execRequest.path, + ); + case 'grep': + return buildExecClientMessageWithGrepResult( + execRequest.id, + execRequest.execId, + execRequest.pattern, + execRequest.path ?? '', + [], + ); + case 'ls': + return buildExecClientMessageWithLsResult( + execRequest.id, + execRequest.execId, + `[tool unavailable: ${reason}]`, + ); + case 'write': + return buildExecClientMessageWithWriteResult(execRequest.id, execRequest.execId, { + error: { path: execRequest.path, error: reason }, + }); + case 'mcp': + return buildExecClientMessageWithMcpResult(execRequest.id, execRequest.execId, { + error: reason, + }); + case 'request_context': + // Caller should answer with env, not reject. Fall through to mcp-style + // error as a defensive last resort. + return buildExecClientMessageWithMcpResult(execRequest.id, execRequest.execId, { error: reason }); + } +} + +// --- Control / wrapper messages --- + +export function buildAgentClientMessageWithExec(execClientMessage: Uint8Array): Uint8Array { + return encodeMessageField(2, execClientMessage); +} + +function encodeExecClientStreamClose(id: number): Uint8Array { + return encodeUint32Field(1, id); +} + +export function buildExecClientControlMessage(id: number): Uint8Array { + const streamClose = encodeExecClientStreamClose(id); + return encodeMessageField(1, streamClose); +} + +export function buildAgentClientMessageWithExecControl(execClientControlMessage: Uint8Array): Uint8Array { + return encodeMessageField(5, execClientControlMessage); +} diff --git a/packages/provider-cursor/src/proto/index.ts b/packages/provider-cursor/src/proto/index.ts new file mode 100644 index 000000000..43e9839ac --- /dev/null +++ b/packages/provider-cursor/src/proto/index.ts @@ -0,0 +1,99 @@ +/** + * Cursor Agent protobuf codec barrel. + * + * 1:1 mapping of opencode-cursor-proxy's proto/ module, Workers-clean: + * Uint8Array + DataView + Web Crypto, no Buffer / node:os / process.cwd. + */ + +export { + encodeVarint, + encodeStringField, + encodeUint32Field, + encodeInt32Field, + encodeInt64Field, + encodeMessageField, + encodeBoolField, + encodeDoubleField, + concatBytes, + encodeProtobufValue, + bytesToHex, + hexToBytes, +} from './encoding.ts'; + +export { decodeVarint, parseProtoFields, parseProtobufValue, parseProtobufStruct, parseProtobufListValue } from './decoding.ts'; +export type { ParsedField } from './decoding.ts'; + +export { + addConnectEnvelope, + readConnectFrame, + isTrailerFrame, + isCompressedFrame, + parseTrailerMetadata, + decompressGzip, + FLAG_COMPRESSED, + FLAG_END_STREAM, + FLAG_TRAILER, +} from './envelope.ts'; +export type { ConnectFrame } from './envelope.ts'; + +export { AgentMode } from './types.ts'; +export type { + OpenAIToolDefinition, + McpExecRequest, + ShellExecRequest, + LsExecRequest, + RequestContextExecRequest, + ReadExecRequest, + GrepExecRequest, + WriteExecRequest, + ExecRequest, + KvServerMessage, + ToolCallInfo, + ParsedToolCall, + ParsedToolCallStarted, + ParsedPartialToolCall, + AgentStreamChunk, + AgentServiceOptions, + AgentChatRequest, + McpResult, + ShellOutcome, + WriteResult, + BlobAnalysis, + ParsedInteractionUpdate, +} from './types.ts'; + +export { + parseExecServerMessage, + buildExecClientMessageWithMcpResult, + buildExecClientMessageWithRequestContextResult, + buildExecClientMessageWithRejectedTool, + buildAgentClientMessageWithExec, + buildExecClientControlMessage, + buildAgentClientMessageWithExecControl, +} from './exec.ts'; + +export { TOOL_FIELD_MAP, TOOL_ARG_SCHEMA, parseToolCall, parseToolCallStartedUpdate, parsePartialToolCallUpdate } from './tool-calls.ts'; + +export { parseKvServerMessage, buildKvClientMessage, buildAgentClientMessageWithKv, analyzeBlobData, extractAssistantContent } from './kv.ts'; +export type { AssistantBlobContent } from './kv.ts'; + +export { encodeBidiRequestId, encodeBidiAppendRequest } from './bidi.ts'; + +export { + encodeMcpToolDefinition, + buildRequestContextEnv, + encodeMcpInstructions, + buildRequestContext, + encodeUserMessage, + encodeUserMessageAction, + encodeConversationAction, + encodeModelDetails, + encodeMcpTools, + encodeMcpDescriptor, + encodeMcpFileSystemOptions, + encodeAgentRunRequest, + encodeAgentClientMessage, +} from './agent-messages.ts'; +export type { McpDescriptorInput, RequestContextEnv } from './agent-messages.ts'; + +export { parseInteractionUpdate, parseCheckpointTokenDetails } from './interaction.ts'; diff --git a/packages/provider-cursor/src/proto/interaction.ts b/packages/provider-cursor/src/proto/interaction.ts new file mode 100644 index 000000000..7a970618f --- /dev/null +++ b/packages/provider-cursor/src/proto/interaction.ts @@ -0,0 +1,116 @@ +import { TEXT_DECODER, parseProtoFields } from './decoding.ts'; +import { parseToolCallStartedUpdate, parsePartialToolCallUpdate } from './tool-calls.ts'; +import type { ParsedInteractionUpdate } from './types.ts'; + +/** + * Parse an InteractionUpdate message. + * + * InteractionUpdate fields: + * field 1: text_delta (TextDeltaUpdate) + * field 2: tool_call_started (ToolCallStartedUpdate) + * field 3: tool_call_completed (ToolCallCompletedUpdate) + * field 4: thinking_delta (ThinkingDeltaUpdate) — reasoning/thinking models + * field 7: partial_tool_call (PartialToolCallUpdate) + * field 8: token_delta (TokenDeltaUpdate) + * field 13: heartbeat + * field 14: turn_ended (TurnEndedUpdate) + */ +export function parseInteractionUpdate(data: Uint8Array): ParsedInteractionUpdate { + const fields = parseProtoFields(data); + + let text: string | null = null; + let thinking: string | null = null; + let isComplete = false; + let isHeartbeat = false; + let tokenDelta: number | null = null; + let toolCallStarted: ParsedInteractionUpdate['toolCallStarted'] = null; + let toolCallCompleted: ParsedInteractionUpdate['toolCallCompleted'] = null; + let partialToolCall: ParsedInteractionUpdate['partialToolCall'] = null; + + for (const field of fields) { + if (field.fieldNumber === 1 && field.wireType === 2 && field.value instanceof Uint8Array) { + const innerFields = parseProtoFields(field.value); + for (const innerField of innerFields) { + if (innerField.fieldNumber === 1 && innerField.wireType === 2 && innerField.value instanceof Uint8Array) { + text = TEXT_DECODER.decode(innerField.value); + } + } + } else if (field.fieldNumber === 2 && field.wireType === 2 && field.value instanceof Uint8Array) { + const parsed = parseToolCallStartedUpdate(field.value); + if (parsed.toolCall) { + toolCallStarted = { + callId: parsed.callId, + modelCallId: parsed.modelCallId, + toolType: parsed.toolCall.toolType, + name: parsed.toolCall.name, + arguments: JSON.stringify(parsed.toolCall.arguments), + }; + } + } else if (field.fieldNumber === 3 && field.wireType === 2 && field.value instanceof Uint8Array) { + const parsed = parseToolCallStartedUpdate(field.value); + if (parsed.toolCall) { + toolCallCompleted = { + callId: parsed.callId, + modelCallId: parsed.modelCallId, + toolType: parsed.toolCall.toolType, + name: parsed.toolCall.name, + arguments: JSON.stringify(parsed.toolCall.arguments), + }; + } + } else if (field.fieldNumber === 4 && field.wireType === 2 && field.value instanceof Uint8Array) { + const innerFields = parseProtoFields(field.value); + for (const innerField of innerFields) { + if (innerField.fieldNumber === 1 && innerField.wireType === 2 && innerField.value instanceof Uint8Array) { + thinking = TEXT_DECODER.decode(innerField.value); + } + } + } else if (field.fieldNumber === 7 && field.wireType === 2 && field.value instanceof Uint8Array) { + const parsed = parsePartialToolCallUpdate(field.value); + partialToolCall = { + callId: parsed.callId, + argsTextDelta: parsed.argsTextDelta, + }; + } else if (field.fieldNumber === 8 && field.wireType === 2 && field.value instanceof Uint8Array) { + // token_delta (TokenDeltaUpdate): a streamed output-token increment. + // `int32 tokens = 1` is a varint (wireType 0) inside the message — the + // running sum over a turn is cursor's own output-token count. + const tokenFields = parseProtoFields(field.value); + for (const tField of tokenFields) { + if (tField.fieldNumber === 1 && tField.wireType === 0) { + tokenDelta = Number(tField.value); + } + } + } else if (field.fieldNumber === 14) { + isComplete = true; + } else if (field.fieldNumber === 13) { + isHeartbeat = true; + } + } + + return { text, thinking, isComplete, isHeartbeat, tokenDelta, toolCallStarted, toolCallCompleted, partialToolCall }; +} + +/** + * Parse the token details out of a conversation_checkpoint_update + * (AgentServerMessage field 3 → ConversationStateStructure). Cursor pushes + * these periodically; they carry the live context accounting the IDE shows. + * + * ConversationStateStructure field 5: token_details (ConversationTokenDetails) + * ConversationTokenDetails field 1: used_tokens (uint32), field 2: max_tokens. + * + * Returns null when the checkpoint carries no token_details. + */ +export function parseCheckpointTokenDetails(data: Uint8Array): { usedTokens: number; maxTokens: number } | null { + for (const field of parseProtoFields(data)) { + if (field.fieldNumber === 5 && field.wireType === 2 && field.value instanceof Uint8Array) { + let usedTokens = 0; + let maxTokens = 0; + for (const tf of parseProtoFields(field.value)) { + if (tf.fieldNumber === 1 && tf.wireType === 0) usedTokens = Number(tf.value); + else if (tf.fieldNumber === 2 && tf.wireType === 0) maxTokens = Number(tf.value); + } + return { usedTokens, maxTokens }; + } + } + return null; +} diff --git a/packages/provider-cursor/src/proto/kv.ts b/packages/provider-cursor/src/proto/kv.ts new file mode 100644 index 000000000..a8f0dd905 --- /dev/null +++ b/packages/provider-cursor/src/proto/kv.ts @@ -0,0 +1,162 @@ +/** + * KV (Key-Value) blob message handling. + * + * The Cursor agent asks us to store/retrieve blobs (set_blob_args / get_blob_args) + * keyed by blob_id. When the backend stores an assistant response in a blob + * instead of streaming it, we extract the text from the blob and surface it. + * + * Proto: + * KvServerMessage: field 1 id (uint32), field 2 get_blob_args, field 3 set_blob_args + * KvClientMessage: field 1 id (uint32), field 2 get_blob_result, field 3 set_blob_result + */ + +import { TEXT_DECODER_FATAL, parseProtoFields } from './decoding.ts'; +import { encodeUint32Field, encodeMessageField, concatBytes } from './encoding.ts'; +import type { KvServerMessage, BlobAnalysis } from './types.ts'; + +export type { KvServerMessage }; + +export function parseKvServerMessage(data: Uint8Array): KvServerMessage { + const fields = parseProtoFields(data); + const result: KvServerMessage = { id: 0, messageType: 'unknown' }; + + for (const field of fields) { + if (field.fieldNumber === 1 && field.wireType === 0) { + result.id = field.value as number; + } else if (field.fieldNumber === 2 && field.wireType === 2 && field.value instanceof Uint8Array) { + result.messageType = 'get_blob_args'; + const argsFields = parseProtoFields(field.value); + for (const af of argsFields) { + if (af.fieldNumber === 1 && af.wireType === 2 && af.value instanceof Uint8Array) { + result.blobId = af.value; + } + } + } else if (field.fieldNumber === 3 && field.wireType === 2 && field.value instanceof Uint8Array) { + result.messageType = 'set_blob_args'; + const argsFields = parseProtoFields(field.value); + for (const af of argsFields) { + if (af.fieldNumber === 1 && af.wireType === 2 && af.value instanceof Uint8Array) { + result.blobId = af.value; + } else if (af.fieldNumber === 2 && af.wireType === 2 && af.value instanceof Uint8Array) { + result.blobData = af.value; + } + } + } + } + + return result; +} + +export function buildKvClientMessage( + id: number, + resultType: 'get_blob_result' | 'set_blob_result', + result: Uint8Array, +): Uint8Array { + const fieldNumber = resultType === 'get_blob_result' ? 2 : 3; + // The oneof value is a message: GetBlobResult { blob_data = 1 } / SetBlobResult + // { error = 1 }. `result` is the raw blob bytes (get) or empty (set/miss); wrap + // non-empty get payloads as GetBlobResult.blob_data (field 1) so cursor reads + // the blob instead of parsing the raw bytes as the result message and stalling. + const inner = resultType === 'get_blob_result' && result.length > 0 ? encodeMessageField(1, result) : result; + return concatBytes(encodeUint32Field(1, id), encodeMessageField(fieldNumber, inner)); +} + +/** + * AgentClientMessage with kv_client_message (field 3). + */ +export function buildAgentClientMessageWithKv(kvClientMessage: Uint8Array): Uint8Array { + return encodeMessageField(3, kvClientMessage); +} + +export function analyzeBlobData(data: Uint8Array): BlobAnalysis { + try { + const text = TEXT_DECODER_FATAL.decode(data); + try { + const json = JSON.parse(text) as Record; + return { type: 'json', json, text }; + } catch { + return { type: 'text', text }; + } + } catch { + // not valid utf-8 + } + + try { + const fields = parseProtoFields(data); + if (fields.length > 0 && fields.length < 100) { + const protoFields: BlobAnalysis['protoFields'] = []; + for (const f of fields) { + const entry: { num: number; wire: number; size: number; text?: string } = { + num: f.fieldNumber, + wire: f.wireType, + size: f.value instanceof Uint8Array ? f.value.length : 0, + }; + if (f.wireType === 2 && f.value instanceof Uint8Array) { + try { + entry.text = TEXT_DECODER_FATAL.decode(f.value); + } catch { + // binary field + } + } + protoFields.push(entry); + } + return { type: 'protobuf', protoFields }; + } + } catch { + // not protobuf + } + + return { type: 'binary' }; +} + +export interface AssistantBlobContent { + blobId: string; + content: string; +} + +interface MessageLike { + role?: unknown; + content?: unknown; + type?: unknown; + text?: unknown; + messages?: unknown[]; +} + +export function extractAssistantContent(blobAnalysis: BlobAnalysis, blobKey: string): AssistantBlobContent[] { + const results: AssistantBlobContent[] = []; + + if (blobAnalysis.type === 'json' && blobAnalysis.json) { + const json = blobAnalysis.json as MessageLike; + + if (json.role === 'assistant') { + const content = json.content; + if (typeof content === 'string' && content.length > 0) { + results.push({ blobId: blobKey, content }); + } else if (Array.isArray(content)) { + for (const part of content as MessageLike[]) { + if (typeof part === 'string') { + results.push({ blobId: blobKey, content: part }); + } else if (part?.type === 'text' && typeof part?.text === 'string') { + results.push({ blobId: blobKey, content: part.text }); + } + } + } + } + + if (Array.isArray(json.messages)) { + for (const msg of json.messages as MessageLike[]) { + if (msg?.role === 'assistant' && typeof msg?.content === 'string') { + results.push({ blobId: blobKey, content: msg.content }); + } + } + } + } else if (blobAnalysis.type === 'protobuf' && blobAnalysis.protoFields) { + for (const field of blobAnalysis.protoFields) { + if (field.text && field.text.length > 50 && !field.text.startsWith('{') && !field.text.startsWith('[')) { + results.push({ blobId: `${blobKey}:f${field.num}`, content: field.text }); + } + } + } + + return results; +} diff --git a/packages/provider-cursor/src/proto/proto_test.ts b/packages/provider-cursor/src/proto/proto_test.ts new file mode 100644 index 000000000..f724233f3 --- /dev/null +++ b/packages/provider-cursor/src/proto/proto_test.ts @@ -0,0 +1,354 @@ +import { describe, expect, test } from 'vitest'; + +import { + encodeVarint, + decodeVarint, + encodeStringField, + encodeInt64Field, + encodeMessageField, + encodeUint32Field, + encodeInt32Field, + encodeProtobufValue, + bytesToHex, + hexToBytes, + concatBytes, + parseProtoFields, + parseProtobufValue, + encodeBidiRequestId, + encodeBidiAppendRequest, + addConnectEnvelope, + readConnectFrame, + isTrailerFrame, + parseInteractionUpdate, + parseCheckpointTokenDetails, + parseExecServerMessage, + parseKvServerMessage, + parseToolCall, + buildKvClientMessage, + buildExecClientMessageWithMcpResult, + buildExecClientMessageWithRejectedTool, + buildAgentClientMessageWithExec, + AgentMode, + type ExecRequest, +} from './index.ts'; + +describe('proto encoding primitives', () => { + test('encodeVarint matches canonical byte sequences', () => { + expect(Array.from(encodeVarint(0))).toEqual([0]); + expect(Array.from(encodeVarint(150))).toEqual([0x96, 0x01]); + // 1 << 35 = 2^35, which exceeds the 5-byte varint max (2^35 - 1) → 6 bytes + expect(Array.from(encodeVarint(1n << 35n))).toHaveLength(6); + }); + + test('encodeVarint round-trips through decodeVarint', () => { + for (const v of [0, 1, 127, 128, 150, 16384, 0x0fffffff]) { + const bytes = encodeVarint(v); + const { value, bytesRead } = decodeVarint(bytes, 0); + expect(value).toBe(v); + expect(bytesRead).toBe(bytes.length); + } + }); + + test('encodeStringField round-trips through parseProtoFields', () => { + const field = encodeStringField(1, 'hello-世界'); + const parsed = parseProtoFields(field); + expect(parsed).toHaveLength(1); + expect(parsed[0]!.fieldNumber).toBe(1); + expect(parsed[0]!.wireType).toBe(2); + expect(new TextDecoder().decode(parsed[0]!.value as Uint8Array)).toBe('hello-世界'); + }); + + test('bytesToHex / hexToBytes round-trip', () => { + const bytes = new Uint8Array([0x00, 0xff, 0xab, 0x12, 0x9f]); + const hex = bytesToHex(bytes); + expect(hex).toBe('00ffab129f'); + expect(Array.from(hexToBytes(hex))).toEqual(Array.from(bytes)); + }); + + test('hexToBytes rejects odd-length input', () => { + expect(() => hexToBytes('abc')).toThrow(TypeError); + }); + + test('encodeInt64Field carries bigint seqno', () => { + const field = encodeInt64Field(3, 42n); + const parsed = parseProtoFields(field); + expect(parsed[0]!.fieldNumber).toBe(3); + expect(parsed[0]!.wireType).toBe(0); + // wire type 0 fields surface as a number value + expect(parsed[0]!.value).toBe(42); + }); +}); + +describe('connect envelope', () => { + test('addConnectEnvelope / readConnectFrame round-trip', () => { + const payload = new Uint8Array([1, 2, 3, 4, 5]); + const framed = addConnectEnvelope(payload, 0); + expect(framed.length).toBe(5 + payload.length); + const frame = readConnectFrame(framed, 0); + expect(frame).not.toBeNull(); + expect(frame!.flags).toBe(0); + expect(Array.from(frame!.payload)).toEqual([1, 2, 3, 4, 5]); + expect(frame!.nextOffset).toBe(framed.length); + }); + + test('readConnectFrame returns null for a partial frame', () => { + const payload = new Uint8Array(10); + const framed = addConnectEnvelope(payload, 0); + const partial = framed.slice(0, 7); + expect(readConnectFrame(partial, 0)).toBeNull(); + }); + + test('trailer flag is detected', () => { + const framed = addConnectEnvelope(new TextEncoder().encode('grpc-status: 0'), 0x80); + const frame = readConnectFrame(framed, 0); + expect(isTrailerFrame(frame!.flags)).toBe(true); + }); +}); + +describe('google.protobuf.Value', () => { + test.each([ + ['null', null, null], + ['number', 3.5, 3.5], + ['string', 'hi', 'hi'], + ['true', true, true], + ['false', false, false], + ])('round-trips %s', (_label, input, expected) => { + expect(parseProtobufValue(encodeProtobufValue(input))).toBe(expected); + }); + + test('round-trips a nested struct', () => { + const input = { a: 1, b: 'x', c: [1, 2, { d: true }] }; + expect(parseProtobufValue(encodeProtobufValue(input))).toEqual(input); + }); +}); + +describe('bidi control messages', () => { + test('encodeBidiAppendRequest carries hex data + requestId + seqno', () => { + const hexData = 'deadbeef'; + const requestId = 'req-123'; + const fields = parseProtoFields(encodeBidiAppendRequest(hexData, requestId, 7n)); + expect(fields).toHaveLength(3); + + const data = fields.find(f => f.fieldNumber === 1); + expect(data?.wireType).toBe(2); + expect(new TextDecoder().decode(data!.value as Uint8Array)).toBe(hexData); + + const seqno = fields.find(f => f.fieldNumber === 3); + expect(seqno?.wireType).toBe(0); + expect(seqno!.value).toBe(7); + + // nested requestId message at field 2 + const reqIdMsg = fields.find(f => f.fieldNumber === 2); + const inner = parseProtoFields(reqIdMsg!.value as Uint8Array); + expect(new TextDecoder().decode(inner[0]!.value as Uint8Array)).toBe(requestId); + }); + + test('encodeBidiRequestId is a single string field', () => { + const fields = parseProtoFields(encodeBidiRequestId('r1')); + expect(fields).toHaveLength(1); + expect(fields[0]!.fieldNumber).toBe(1); + expect(new TextDecoder().decode(fields[0]!.value as Uint8Array)).toBe('r1'); + }); +}); + +describe('parseInteractionUpdate', () => { + test('extracts text_delta', () => { + // InteractionUpdate.field1 = TextDeltaUpdate.field1 = text + const update = encodeMessageField(1, encodeStringField(1, 'hello')); + const parsed = parseInteractionUpdate(update); + expect(parsed.text).toBe('hello'); + expect(parsed.isComplete).toBe(false); + }); + + test('extracts thinking_delta', () => { + const update = encodeMessageField(4, encodeStringField(1, 'reasoning...')); + expect(parseInteractionUpdate(update).thinking).toBe('reasoning...'); + }); + + test('flags turn_ended on field 14', () => { + // field 14, wire type 0, value 0 — presence is what matters. Build the + // tag byte explicitly: encodeUint32Field omits zero values. + const update = new Uint8Array([(14 << 3) | 0, 0]); + expect(parseInteractionUpdate(update).isComplete).toBe(true); + }); + + test('flags heartbeat on field 13', () => { + const update = new Uint8Array([(13 << 3) | 0, 0]); + expect(parseInteractionUpdate(update).isHeartbeat).toBe(true); + }); + + test('reads token_delta (field 8 → TokenDeltaUpdate.tokens=1, a varint)', () => { + // InteractionUpdate.field8 = TokenDeltaUpdate{ int32 tokens = 1 }. + const update = encodeMessageField(8, encodeInt32Field(1, 7)); + const parsed = parseInteractionUpdate(update); + expect(parsed.tokenDelta).toBe(7); + expect(parsed.text).toBeNull(); // must NOT be mis-decoded as text + }); + + test('token_delta absent → tokenDelta null', () => { + const update = encodeMessageField(1, encodeStringField(1, 'hi')); + expect(parseInteractionUpdate(update).tokenDelta).toBeNull(); + }); +}); + +describe('parseCheckpointTokenDetails', () => { + test('reads used/max tokens (ConversationStateStructure.field5 → {used=1, max=2})', () => { + // ConversationTokenDetails { used_tokens=1, max_tokens=2 } under field 5. + const details = concatBytes(encodeUint32Field(1, 21132), encodeUint32Field(2, 262000)); + const checkpoint = encodeMessageField(5, details); + expect(parseCheckpointTokenDetails(checkpoint)).toEqual({ usedTokens: 21132, maxTokens: 262000 }); + }); + + test('no token_details → null', () => { + // A checkpoint with only root_prompt_messages_json (field 1), no field 5. + const checkpoint = encodeMessageField(1, new Uint8Array([1, 2, 3])); + expect(parseCheckpointTokenDetails(checkpoint)).toBeNull(); + }); +}); + +describe('parseExecServerMessage', () => { + function buildMcpExec(id: number, mcpArgs: Uint8Array): Uint8Array { + return concatBytes(encodeUint32Field(1, id), encodeMessageField(11, mcpArgs)); + } + + test('parses an mcp exec request', () => { + // McpArgs: field1 name, field3 toolCallId, field4 providerIdentifier, field5 toolName + const mcpArgs = concatBytes( + encodeStringField(1, 'search'), + encodeStringField(3, 'call_1'), + encodeStringField(4, 'cursor-tools'), + encodeStringField(5, 'search'), + ); + const exec = buildMcpExec(42, mcpArgs); + const parsed = parseExecServerMessage(exec) as Extract; + expect(parsed).not.toBeNull(); + expect(parsed.type).toBe('mcp'); + expect(parsed.id).toBe(42); + expect(parsed.name).toBe('search'); + expect(parsed.toolCallId).toBe('call_1'); + expect(parsed.providerIdentifier).toBe('cursor-tools'); + expect(parsed.toolName).toBe('search'); + }); + + test('parses a shell exec request (field 2)', () => { + const shellArgs = concatBytes(encodeStringField(1, 'ls -la'), encodeStringField(2, '/tmp')); + const exec = concatBytes(encodeUint32Field(1, 7), encodeMessageField(2, shellArgs)); + const parsed = parseExecServerMessage(exec) as Extract; + expect(parsed.type).toBe('shell'); + expect(parsed.id).toBe(7); + expect(parsed.command).toBe('ls -la'); + expect(parsed.cwd).toBe('/tmp'); + }); + + test('parses a request_context exec request (field 10)', () => { + const exec = concatBytes(encodeUint32Field(1, 9), encodeMessageField(10, new Uint8Array(0))); + const parsed = parseExecServerMessage(exec) as Extract; + expect(parsed.type).toBe('request_context'); + expect(parsed.id).toBe(9); + }); +}); + +describe('exec result / reject encoders', () => { + test('buildExecClientMessageWithMcpResult encodes id + mcp success', () => { + const msg = buildExecClientMessageWithMcpResult(5, undefined, { success: { content: 'ok' } }); + const fields = parseProtoFields(msg); + expect(fields.find(f => f.fieldNumber === 1)!.value).toBe(5); + expect(fields.find(f => f.fieldNumber === 11)).toBeDefined(); + }); + + test('buildExecClientMessageWithRejectedTool emits a shell failure', () => { + const req: Extract = { + type: 'shell', + id: 3, + execId: 'e1', + command: 'rm -rf /', + cwd: '/', + }; + const msg = buildExecClientMessageWithRejectedTool(req, 'gateway cannot execute shell'); + const fields = parseProtoFields(msg); + expect(fields.find(f => f.fieldNumber === 1)!.value).toBe(3); + // shell result lives at field 2 + expect(fields.find(f => f.fieldNumber === 2)).toBeDefined(); + // execId at field 15 + expect(new TextDecoder().decode(fields.find(f => f.fieldNumber === 15)!.value as Uint8Array)).toBe('e1'); + }); + + test('buildAgentClientMessageWithExec wraps at AgentClientMessage field 2', () => { + const inner = buildExecClientMessageWithMcpResult(1, undefined, { error: 'nope' }); + const wrapped = buildAgentClientMessageWithExec(inner); + const fields = parseProtoFields(wrapped); + expect(fields[0]!.fieldNumber).toBe(2); + expect(fields[0]!.wireType).toBe(2); + }); +}); + +describe('parseKvServerMessage', () => { + test('parses set_blob_args', () => { + const blobId = new Uint8Array([1, 2, 3]); + const blobData = new Uint8Array([0xaa, 0xbb]); + const setBlobArgs = concatBytes(encodeMessageField(1, blobId), encodeMessageField(2, blobData)); + const kv = concatBytes(encodeUint32Field(1, 99), encodeMessageField(3, setBlobArgs)); + const parsed = parseKvServerMessage(kv); + expect(parsed.id).toBe(99); + expect(parsed.messageType).toBe('set_blob_args'); + expect(Array.from(parsed.blobId!)).toEqual([1, 2, 3]); + expect(Array.from(parsed.blobData!)).toEqual([0xaa, 0xbb]); + }); +}); + +describe('AgentMode enum', () => { + test('has the expected modes', () => { + expect(AgentMode.AGENT).toBe(1); + expect(AgentMode.ASK).toBe(2); + expect(AgentMode.UNSPECIFIED).toBe(0); + }); +}); + +describe('buildKvClientMessage', () => { + test('wraps get_blob_result payload as GetBlobResult{ blob_data = 1 }', () => { + const blob = new Uint8Array([0x7b, 0x22, 0x61, 0x22, 0x7d]); // {"a"} + const msg = buildKvClientMessage(7, 'get_blob_result', blob); + const fields = parseProtoFields(msg); + expect((fields.find(f => f.fieldNumber === 1)!.value as number)).toBe(7); // id + // field 2 is the GetBlobResult message, whose field 1 carries the raw blob — + // cursor parses field 2 as the result message, so the bytes must NOT sit + // directly in field 2 (that regressed to a stall). + const getBlobResult = fields.find(f => f.fieldNumber === 2)!.value as Uint8Array; + const inner = parseProtoFields(getBlobResult); + expect(Array.from(inner.find(f => f.fieldNumber === 1)!.value as Uint8Array)).toEqual(Array.from(blob)); + }); + + test('set_blob_result stays an empty SetBlobResult message (field 3)', () => { + const msg = buildKvClientMessage(3, 'set_blob_result', new Uint8Array(0)); + const fields = parseProtoFields(msg); + const setBlobResult = fields.find(f => f.fieldNumber === 3)!.value as Uint8Array; + expect(setBlobResult.length).toBe(0); + }); +}); + +describe('parseToolCall nested-wrapper heuristic', () => { + test('preserves a shell command that starts with a newline', () => { + // 0x0a is byte-identical with the UTF-8 encoding of `\n`, so a leading + // LF in a scalar string used to false-positive into the nested-wrapper + // reparse and corrupt the command. The strict length-consistency check + // now keeps such strings intact. + const shellArgs = encodeStringField(1, '\ncat foo'); + const toolCall = encodeMessageField(1, shellArgs); + const parsed = parseToolCall(toolCall); + expect(parsed.toolType).toBe('shell_tool_call'); + expect(parsed.arguments.command).toBe('\ncat foo'); + }); + + test('preserves a multi-line edit oldString that starts with a newline', () => { + const editArgs = concatBytes( + encodeStringField(1, 'foo.ts'), + encodeStringField(2, '\nconst x = 1;\n'), + encodeStringField(3, '\nconst y = 2;\n'), + ); + const toolCall = encodeMessageField(12, editArgs); + const parsed = parseToolCall(toolCall); + expect(parsed.toolType).toBe('edit_tool_call'); + expect(parsed.arguments.oldString).toBe('\nconst x = 1;\n'); + expect(parsed.arguments.newString).toBe('\nconst y = 2;\n'); + }); +}); diff --git a/packages/provider-cursor/src/proto/stream-cpp.ts b/packages/provider-cursor/src/proto/stream-cpp.ts new file mode 100644 index 000000000..a6247c772 --- /dev/null +++ b/packages/provider-cursor/src/proto/stream-cpp.ts @@ -0,0 +1,122 @@ +/** + * Cursor StreamCpp (Tab / Copilot++) request encoding + response decoding. + * + * Field numbers from the reversed agent/aiserver v1 protos (cursor-byok), and + * the minimal populated field set that avoids the server's "internal error" is + * mirrored from the working reference clients (daniprol/cursor-tabcomplete, + * wisdgod/cursor-tab): current_file + a stub file_diff_histories entry + + * cpp_intent_info + timestamps. Images/LSP/context are sent empty. + */ + +import { TEXT_DECODER, parseProtoFields } from './decoding.ts'; +import { concatBytes, encodeBoolField, encodeDoubleField, encodeInt32Field, encodeMessageField, encodeStringField } from './encoding.ts'; + +export interface StreamCppRequestInput { + relativePath: string; + contents: string; + cursorLine: number; // 0-based + cursorColumn: number; // 0-based + languageId: string; + modelName: string; + workspaceRootPath?: string; + /** Recent-edit hint; a non-empty stub keeps the server from erroring. */ + diffHistory?: string[]; +} + +// CurrentFileInfo: path=1, contents=2, cursor_position=3, language_id=5, +// total_number_of_lines=8, contents_start_at_line=9, workspace_root_path=19, +// line_ending=20. CursorPosition: line=1, column=2. +const encodeCurrentFileInfo = (input: StreamCppRequestInput): Uint8Array => { + const cursorPosition = concatBytes(encodeInt32Field(1, input.cursorLine), encodeInt32Field(2, input.cursorColumn)); + const parts: Uint8Array[] = [ + encodeStringField(1, input.relativePath), + encodeStringField(2, input.contents), + encodeMessageField(3, cursorPosition), + encodeStringField(5, input.languageId), + encodeInt32Field(8, input.contents.split('\n').length), + encodeInt32Field(9, 0), + encodeStringField(19, input.workspaceRootPath ?? '/workspace'), + encodeStringField(20, '\n'), + ]; + return concatBytes(...parts); +}; + +// CppFileDiffHistory: file_name=1, diff_history=2 (repeated string). +const encodeFileDiffHistory = (fileName: string, diffs: readonly string[]): Uint8Array => + concatBytes(encodeStringField(1, fileName), ...diffs.map(d => encodeStringField(2, d))); + +// StreamCppRequest: current_file=1, model_name=3, file_diff_histories=7, +// cpp_intent_info=16, client_time=21, time_since_request_start=23, +// time_at_request_send=24, client_timezone_offset=25, lsp_suggested_items=26, +// supports_cpt=27, supports_crlf_cpt=28. +export const encodeStreamCppRequest = (input: StreamCppRequestInput): Uint8Array => { + const nowSec = Date.now() / 1000; + const diffs = input.diffHistory && input.diffHistory.length > 0 ? input.diffHistory : ['1+| \n']; + const cppIntentInfo = encodeStringField(1, 'line_change'); // CppIntentInfo.source = 1 + const parts: Uint8Array[] = [ + encodeMessageField(1, encodeCurrentFileInfo(input)), + encodeStringField(3, input.modelName), + encodeMessageField(7, encodeFileDiffHistory(input.relativePath, diffs)), + encodeMessageField(16, cppIntentInfo), + encodeDoubleField(21, nowSec), + encodeDoubleField(23, 0), + encodeDoubleField(24, nowSec), + encodeDoubleField(25, 0), + encodeMessageField(26, new Uint8Array(0)), // empty LspSuggestedItems + encodeBoolField(27, false), + encodeBoolField(28, false), + ]; + return concatBytes(...parts); +}; + +// range_to_replace (field 11) is a 2-field LineRange on the wire — field 1 the +// start line, field 2 the end line, both 1-indexed. A live capture shows only +// fields {1,2} (cursor-unchained's proto declares a 4-field RangeToReplace with +// columns, but no column fields appear on the wire). The end line's +// inclusive/exclusive meaning is NOT fixed: it co-varies with whether `text` +// ends in a newline, so the splice in applyRewrite ignores this value and +// instead balances newline counts (see applyRewrite in completions.ts). We keep +// the field decoded for reference and future telemetry. +export interface StreamCppLineRange { + startLineNumber: number; + endLine: number; +} + +export interface StreamCppResponseFrame { + text: string; + doneStream: boolean; + doneEdit: boolean; + beginEdit: boolean; + rangeToReplace?: StreamCppLineRange; + shouldRemoveLeadingEol: boolean; +} + +const asBytes = (v: Uint8Array | number | bigint): Uint8Array => (v instanceof Uint8Array ? v : new Uint8Array(0)); +const asNumber = (v: Uint8Array | number | bigint): number => (typeof v === 'number' ? v : typeof v === 'bigint' ? Number(v) : 0); + +// StreamCppResponse: text=1, suggestion_start_line=2, done_stream=4, +// range_to_replace=11 (LineRange), cursor_prediction_target=12, done_edit=13, +// begin_edit=15, should_remove_leading_eol=16. +export const decodeStreamCppResponse = (bytes: Uint8Array): StreamCppResponseFrame => { + const frame: StreamCppResponseFrame = { text: '', doneStream: false, doneEdit: false, beginEdit: false, shouldRemoveLeadingEol: false }; + for (const field of parseProtoFields(bytes)) { + switch (field.fieldNumber) { + case 1: if (field.wireType === 2) frame.text = TEXT_DECODER.decode(asBytes(field.value)); break; + case 4: frame.doneStream = asNumber(field.value) !== 0; break; + case 11: if (field.wireType === 2) { + const range: StreamCppLineRange = { startLineNumber: 0, endLine: 0 }; + for (const f of parseProtoFields(asBytes(field.value))) { + if (f.fieldNumber === 1) range.startLineNumber = asNumber(f.value); + else if (f.fieldNumber === 2) range.endLine = asNumber(f.value); + } + frame.rangeToReplace = range; + break; + } + case 13: frame.doneEdit = asNumber(field.value) !== 0; break; + case 15: frame.beginEdit = asNumber(field.value) !== 0; break; + case 16: frame.shouldRemoveLeadingEol = asNumber(field.value) !== 0; break; + default: break; + } + } + return frame; +}; diff --git a/packages/provider-cursor/src/proto/stream-cpp_test.ts b/packages/provider-cursor/src/proto/stream-cpp_test.ts new file mode 100644 index 000000000..53befe160 --- /dev/null +++ b/packages/provider-cursor/src/proto/stream-cpp_test.ts @@ -0,0 +1,83 @@ +import { describe, expect, test } from 'vitest'; + +import { decodeVarint, parseProtoFields } from './decoding.ts'; +import { encodeDoubleField, encodeFieldTag, encodeInt32Field, encodeMessageField, encodeStringField } from './encoding.ts'; +import { decodeStreamCppResponse, encodeStreamCppRequest } from './stream-cpp.ts'; + +describe('encodeFieldTag (varint tags — regression for fields >= 16)', () => { + test('single-byte tag for field <= 15', () => { + expect(Array.from(encodeFieldTag(1, 2))).toEqual([(1 << 3) | 2]); + expect(Array.from(encodeFieldTag(15, 0))).toEqual([(15 << 3) | 0]); // 120 + }); + test('multi-byte varint tag for field >= 16', () => { + // field 16, wire 2 → tag 130 → varint [0x82, 0x01] + expect(Array.from(encodeFieldTag(16, 2))).toEqual([0x82, 0x01]); + // field 21, wire 1 (double) → tag 169 → varint [0xa9, 0x01] + expect(Array.from(encodeFieldTag(21, 1))).toEqual([0xa9, 0x01]); + }); + test('field encoders emit correctly-tagged fields for field >= 16', () => { + // A string at field 20 must round-trip through the generic parser. + const bytes = encodeStringField(20, 'hi'); + const fields = parseProtoFields(bytes); + expect(fields).toHaveLength(1); + expect(fields[0].fieldNumber).toBe(20); + expect(fields[0].wireType).toBe(2); + // A double at field 24 must not corrupt the following field. + const combined = new Uint8Array([...encodeDoubleField(24, 3), ...encodeInt32Field(8, 42)]); + const parsed = parseProtoFields(combined); + expect(parsed.map(f => f.fieldNumber)).toEqual([24, 8]); + expect(parsed[1].value).toBe(42); + }); +}); + +describe('encodeStreamCppRequest', () => { + test('encodes current_file + model_name + timestamps without misaligning fields', () => { + const bytes = encodeStreamCppRequest({ + relativePath: 'a.py', contents: 'x = ', cursorLine: 0, cursorColumn: 4, languageId: 'python', modelName: 'fast', + }); + const top = parseProtoFields(bytes); + const byNum = new Map(top.map(f => [f.fieldNumber, f])); + // current_file=1 (msg), model_name=3 (string), file_diff_histories=7 (msg), + // cpp_intent_info=16 (msg), client_time=21/time=23/24/tz=25 (double), lsp=26, supports_cpt=27/28. + expect(byNum.has(1)).toBe(true); + expect(byNum.get(3)!.wireType).toBe(2); + expect(new TextDecoder().decode(byNum.get(3)!.value as Uint8Array)).toBe('fast'); + expect(byNum.has(16)).toBe(true); // survived past the >=16 boundary + expect(byNum.get(21)!.wireType).toBe(1); // double + // current_file nested: path=1, contents=2, cursor_position=3 + const cf = parseProtoFields(byNum.get(1)!.value as Uint8Array); + const cfByNum = new Map(cf.map(f => [f.fieldNumber, f])); + expect(new TextDecoder().decode(cfByNum.get(2)!.value as Uint8Array)).toBe('x = '); + const pos = parseProtoFields(cfByNum.get(3)!.value as Uint8Array); + expect(pos.find(f => f.fieldNumber === 2)!.value).toBe(4); // column + }); +}); + +// Build a StreamCppResponse frame body from field parts. +const respFrame = (parts: Uint8Array[]): Uint8Array => { + const total = parts.reduce((n, p) => n + p.length, 0); + const out = new Uint8Array(total); let o = 0; + for (const p of parts) { out.set(p, o); o += p.length; } + return out; +}; + +describe('decodeStreamCppResponse', () => { + test('reads streamed text (field 1)', () => { + expect(decodeStreamCppResponse(encodeStringField(1, 'return ')).text).toBe('return '); + }); + test('reads range_to_replace (field 11) as a LineRange with exclusive end', () => { + // LineRange { start_line=1, end_line=3 } → replace 1-indexed lines [1, 3) + const range = respFrame([encodeInt32Field(1, 1), encodeInt32Field(2, 3)]); + const decoded = decodeStreamCppResponse(encodeMessageField(11, range)); + expect(decoded.rangeToReplace).toEqual({ startLineNumber: 1, endLine: 3 }); + }); + test('reads done_stream (field 4)', () => { + const bytes = respFrame([encodeFieldTag(4, 0), new Uint8Array([1])]); + expect(decodeStreamCppResponse(bytes).doneStream).toBe(true); + }); +}); + +// Sanity: decodeVarint is used by the parser we rely on. +test('decodeVarint reads a 2-byte varint', () => { + expect(decodeVarint(new Uint8Array([0x82, 0x01]), 0)).toEqual({ value: 130, bytesRead: 2 }); +}); diff --git a/packages/provider-cursor/src/proto/tool-calls.ts b/packages/provider-cursor/src/proto/tool-calls.ts new file mode 100644 index 000000000..471a60ebb --- /dev/null +++ b/packages/provider-cursor/src/proto/tool-calls.ts @@ -0,0 +1,157 @@ +import { TEXT_DECODER, decodeVarint, parseProtoFields } from './decoding.ts'; +import type { ParsedToolCall, ParsedToolCallStarted, ParsedPartialToolCall } from './types.ts'; + +export const TOOL_FIELD_MAP: Record = { + 1: { type: 'shell_tool_call', name: 'bash' }, + 3: { type: 'delete_tool_call', name: 'delete' }, + 4: { type: 'glob_tool_call', name: 'glob' }, + 5: { type: 'grep_tool_call', name: 'grep' }, + 8: { type: 'read_tool_call', name: 'read' }, + 9: { type: 'update_todos_tool_call', name: 'todowrite' }, + 10: { type: 'read_todos_tool_call', name: 'todoread' }, + 12: { type: 'edit_tool_call', name: 'edit' }, + 13: { type: 'ls_tool_call', name: 'list' }, + 14: { type: 'read_lints_tool_call', name: 'read_lints' }, + 15: { type: 'mcp_tool_call', name: 'mcp' }, + 16: { type: 'sem_search_tool_call', name: 'semantic_search' }, + 17: { type: 'create_plan_tool_call', name: 'create_plan' }, + 18: { type: 'web_search_tool_call', name: 'web_search' }, + 19: { type: 'task_tool_call', name: 'task' }, + 20: { type: 'list_mcp_resources_tool_call', name: 'list_mcp_resources' }, + 21: { type: 'read_mcp_resource_tool_call', name: 'read_mcp_resource' }, + 22: { type: 'apply_agent_diff_tool_call', name: 'apply_diff' }, + 23: { type: 'ask_question_tool_call', name: 'ask_question' }, + 24: { type: 'fetch_tool_call', name: 'webfetch' }, + 25: { type: 'switch_mode_tool_call', name: 'switch_mode' }, + 26: { type: 'exa_search_tool_call', name: 'exa_search' }, + 27: { type: 'exa_fetch_tool_call', name: 'exa_fetch' }, + 28: { type: 'generate_image_tool_call', name: 'generate_image' }, + 29: { type: 'record_screen_tool_call', name: 'record_screen' }, + 30: { type: 'computer_use_tool_call', name: 'computer_use' }, +}; + +export const TOOL_ARG_SCHEMA: Record> = { + shell_tool_call: { 1: 'command', 2: 'description', 3: 'working_directory' }, + delete_tool_call: { 1: 'filePath' }, + glob_tool_call: { 1: 'pattern', 2: 'path' }, + grep_tool_call: { 1: 'pattern', 2: 'path', 3: 'include' }, + read_tool_call: { 1: 'filePath', 2: 'offset', 3: 'limit' }, + update_todos_tool_call: { 1: 'todos' }, + read_todos_tool_call: {}, + edit_tool_call: { 1: 'filePath', 2: 'oldString', 3: 'newString', 4: 'replaceAll' }, + ls_tool_call: { 1: 'path', 2: 'ignore' }, + read_lints_tool_call: {}, + mcp_tool_call: { 1: 'provider_identifier', 2: 'tool_name', 3: 'tool_call_id', 4: 'args' }, + sem_search_tool_call: { 1: 'query', 2: 'path' }, + create_plan_tool_call: { 1: 'plan' }, + web_search_tool_call: { 1: 'query' }, + task_tool_call: { 1: 'description', 2: 'prompt', 3: 'subagent_type' }, + list_mcp_resources_tool_call: { 1: 'provider_identifier' }, + read_mcp_resource_tool_call: { 1: 'provider_identifier', 2: 'uri' }, + apply_agent_diff_tool_call: { 1: 'filePath', 2: 'diff' }, + ask_question_tool_call: { 1: 'question' }, + fetch_tool_call: { 1: 'url', 2: 'format' }, + switch_mode_tool_call: { 1: 'mode' }, + exa_search_tool_call: { 1: 'query' }, + exa_fetch_tool_call: { 1: 'url' }, + generate_image_tool_call: { 1: 'prompt' }, + record_screen_tool_call: { 1: 'duration' }, + computer_use_tool_call: { 1: 'action', 2: 'text', 3: 'coordinate' }, +}; + +export function parseToolCall(data: Uint8Array): ParsedToolCall { + const fields = parseProtoFields(data); + let toolType = 'unknown'; + let name = 'unknown'; + const args: Record = {}; + + for (const field of fields) { + const toolInfo = TOOL_FIELD_MAP[field.fieldNumber]; + if (toolInfo && field.wireType === 2 && field.value instanceof Uint8Array) { + toolType = toolInfo.type; + name = toolInfo.name; + + const argSchema = TOOL_ARG_SCHEMA[toolType] || {}; + const toolFields = parseProtoFields(field.value); + + for (const tf of toolFields) { + const argName = argSchema[tf.fieldNumber] || `field_${tf.fieldNumber}`; + + if (tf.wireType === 2 && tf.value instanceof Uint8Array) { + try { + let strValue = TEXT_DECODER.decode(tf.value); + + if (tf.value.length > 2 && tf.value[0] === 0x0a) { + // The leading 0x0a byte is byte-identical with the UTF-8 + // encoding of `\n`, so a legitimate string beginning with LF + // is indistinguishable from a wrapped `{ field 1 = string }` + // by prefix alone. Read the declared inner length from the + // varint at byte 1: only when 1 + varintBytes + declared + // exactly equals the buffer size is this actually a wrapped + // message (parseProtoFields silently truncates on overflow, + // so its returned .value.length can't distinguish the two). + const lengthInfo = decodeVarint(tf.value, 1); + if (1 + lengthInfo.bytesRead + lengthInfo.value === tf.value.length) { + strValue = TEXT_DECODER.decode(tf.value.subarray(1 + lengthInfo.bytesRead)); + } + } + + args[argName] = strValue; + } catch { + args[argName] = ``; + } + } else if (tf.wireType === 0) { + if (argName === 'replaceAll') { + args[argName] = tf.value === 1; + } else { + args[argName] = tf.value; + } + } + } + break; + } + } + + return { toolType, name, arguments: args }; +} + +export function parseToolCallStartedUpdate(data: Uint8Array): ParsedToolCallStarted { + const fields = parseProtoFields(data); + let callId = ''; + let modelCallId = ''; + let toolCall: ParsedToolCall | null = null; + + for (const field of fields) { + if (field.fieldNumber === 1 && field.wireType === 2 && field.value instanceof Uint8Array) { + callId = TEXT_DECODER.decode(field.value); + } else if (field.fieldNumber === 2 && field.wireType === 2 && field.value instanceof Uint8Array) { + toolCall = parseToolCall(field.value); + } else if (field.fieldNumber === 3 && field.wireType === 2 && field.value instanceof Uint8Array) { + modelCallId = TEXT_DECODER.decode(field.value); + } + } + + return { callId, modelCallId, toolCall }; +} + +export function parsePartialToolCallUpdate(data: Uint8Array): ParsedPartialToolCall { + const fields = parseProtoFields(data); + let callId = ''; + let modelCallId = ''; + let argsTextDelta = ''; + let toolCall: ParsedToolCall | null = null; + + for (const field of fields) { + if (field.fieldNumber === 1 && field.wireType === 2 && field.value instanceof Uint8Array) { + callId = TEXT_DECODER.decode(field.value); + } else if (field.fieldNumber === 2 && field.wireType === 2 && field.value instanceof Uint8Array) { + toolCall = parseToolCall(field.value); + } else if (field.fieldNumber === 3 && field.wireType === 2 && field.value instanceof Uint8Array) { + argsTextDelta = TEXT_DECODER.decode(field.value); + } else if (field.fieldNumber === 4 && field.wireType === 2 && field.value instanceof Uint8Array) { + modelCallId = TEXT_DECODER.decode(field.value); + } + } + + return { callId, modelCallId, argsTextDelta, toolCall }; +} diff --git a/packages/provider-cursor/src/proto/types.ts b/packages/provider-cursor/src/proto/types.ts new file mode 100644 index 000000000..3a663da51 --- /dev/null +++ b/packages/provider-cursor/src/proto/types.ts @@ -0,0 +1,249 @@ +/** + * Cursor Agent protobuf message types. + * + * Field numbers mirror the agent/v1 + aiserver/v1 protos reversed across + * opencode-cursor-proxy, cursor-byok, and OmniRoute. See + * cursor-http11-bidi-protocol memory for the endpoint lineage. + */ + +import type { SelectedImageInput } from './agent-messages.ts'; + +export enum AgentMode { + UNSPECIFIED = 0, + AGENT = 1, + ASK = 2, + PLAN = 3, + DEBUG = 4, + TRIAGE = 5, +} + +export interface OpenAIToolDefinition { + type: 'function'; + function: { + name: string; + description?: string; + parameters?: Record; + }; +} + +export interface McpExecRequest { + id: number; + execId?: string; + name: string; + args: Record; + toolCallId: string; + providerIdentifier: string; + toolName: string; +} + +export interface ShellExecRequest { + type: 'shell'; + id: number; + execId?: string; + command: string; + cwd?: string; +} + +export interface LsExecRequest { + type: 'ls'; + id: number; + execId?: string; + path: string; +} + +export interface RequestContextExecRequest { + type: 'request_context'; + id: number; + execId?: string; +} + +export interface ReadExecRequest { + type: 'read'; + id: number; + execId?: string; + path: string; +} + +export interface GrepExecRequest { + type: 'grep'; + id: number; + execId?: string; + pattern: string; + path?: string; + glob?: string; +} + +export interface WriteExecRequest { + type: 'write'; + id: number; + execId?: string; + path: string; + fileText: string; + toolCallId?: string; + returnFileContentAfterWrite?: boolean; + fileBytes?: Uint8Array; +} + +export type ExecRequest = + | (McpExecRequest & { type: 'mcp' }) + | ShellExecRequest + | LsExecRequest + | RequestContextExecRequest + | ReadExecRequest + | GrepExecRequest + | WriteExecRequest; + +export interface KvServerMessage { + id: number; + messageType: 'get_blob_args' | 'set_blob_args' | 'unknown'; + blobId?: Uint8Array; + blobData?: Uint8Array; +} + +export interface ToolCallInfo { + callId: string; + modelCallId?: string; + toolType: string; + name: string; + arguments: string; +} + +export interface ParsedToolCall { + toolType: string; + name: string; + arguments: Record; +} + +export interface ParsedToolCallStarted { + callId: string; + modelCallId: string; + toolCall: ParsedToolCall | null; +} + +export interface ParsedPartialToolCall { + callId: string; + modelCallId: string; + argsTextDelta: string; + toolCall: ParsedToolCall | null; +} + +export interface AgentStreamChunk { + type: + | 'text' + | 'thinking' + | 'token' + | 'checkpoint' + | 'done' + | 'error' + | 'tool_call_started' + | 'tool_call_completed' + | 'partial_tool_call' + | 'exec_request' + | 'heartbeat' + | 'exec_server_abort' + | 'interaction_query' + | 'kv_blob_assistant'; + content?: string; + error?: string; + toolCall?: ToolCallInfo; + partialArgs?: string; + execRequest?: ExecRequest; + queryId?: number; + queryType?: string; + blobContent?: string; + // token chunk: cursor's TokenDeltaUpdate.tokens increment (output ticker). + tokens?: number; + // checkpoint chunk: cursor's ConversationTokenDetails — live context + // occupancy (usedTokens) against the active model's window (maxTokens). + usedTokens?: number; + maxTokens?: number; +} + +export interface AgentServiceOptions { + baseUrl?: string; + privacyMode?: boolean; + workspacePath?: string; + clientVersion?: string; + osVersion?: string; + shell?: string; + timezone?: string; +} + +export interface AgentChatRequest { + message: string; + model?: string; + mode?: AgentMode; + conversationId?: string; + tools?: OpenAIToolDefinition[]; + // Opt into Cursor Max Mode (larger context window) for this turn. + maxMode?: boolean; + // Inline images for the current user turn (SelectedContext.selected_images). + images?: readonly SelectedImageInput[]; +} + +export interface McpResult { + success?: { content: string; isError?: boolean }; + error?: string; +} + +export interface ShellOutcome { + command: string; + cwd: string; + stdout: string; + stderr: string; + exitCode: number; + executionTimeMs?: number; +} + +export interface WriteResult { + success?: { + path: string; + linesCreated: number; + fileSize: number; + fileContentAfterWrite?: string; + }; + error?: { + path: string; + error: string; + }; +} + +export interface BlobAnalysis { + type: 'json' | 'text' | 'protobuf' | 'binary'; + json?: unknown; + text?: string; + protoFields?: Array<{ + num: number; + wire: number; + size: number; + text?: string; + }>; +} + +export interface ParsedInteractionUpdate { + text: string | null; + thinking: string | null; + isComplete: boolean; + isHeartbeat: boolean; + // TokenDeltaUpdate.tokens (field 8) — a streamed output-token increment. + // null when this update carries no token_delta. + tokenDelta: number | null; + toolCallStarted: { + callId: string; + modelCallId: string; + toolType: string; + name: string; + arguments: string; + } | null; + toolCallCompleted: { + callId: string; + modelCallId: string; + toolType: string; + name: string; + arguments: string; + } | null; + partialToolCall: { + callId: string; + argsTextDelta: string; + } | null; +} diff --git a/packages/provider-cursor/src/provider.ts b/packages/provider-cursor/src/provider.ts new file mode 100644 index 000000000..1a3a62607 --- /dev/null +++ b/packages/provider-cursor/src/provider.ts @@ -0,0 +1,276 @@ +import { ensureCursorAccessToken, mintCursorAccessToken } from './access-token-cache.ts'; +import { CursorSessionTerminatedError } from './auth/oauth.ts'; +import { generateCursorChecksum } from './checksum.ts'; +import { applyStops, completionsResponseBody, type CompletionsUsage, estimateCursorTabTokens, extractInsertion, languageIdForCompletion, parsePrefixSuffix, streamCppInputForPrefixSuffix } from './completions.ts'; +import { assertCursorUpstreamRecord, type CursorUpstreamConfig } from './config.ts'; +import { readObservedContext } from './context-window.ts'; +import { callCursorChatCompletions, syntheticErrorResponse, type CursorCallEffects } from './fetch.ts'; +import { cursorChatCompletionsChain } from './interceptors/chat-completions/index.ts'; +import type { ChatCompletionsBoundaryCtx } from './interceptors/chat-completions/types.ts'; +import { cursorRawToProviderModel, cursorTabModel, fetchCursorCatalog, resolveCursorWireModel } from './models.ts'; +import { pricingForCursorModelKey } from './pricing.ts'; +import type { StreamCppRequestInput } from './proto/stream-cpp.ts'; +import { assertCursorUpstreamState, type CursorUpstreamState } from './state.ts'; +import { callStreamCpp } from './stream-cpp-transport.ts'; +import { detectPromptFormat, parseZetaV0318, renderZetaV0318Output, streamCppInputForZeta } from './zeta-format.ts'; +import { parseZetaV0615, renderV0615Output, streamCppInputForV0615 } from './zeta-v0615.ts'; +import { runInterceptors } from '@floway-dev/interceptor'; +import type { ChatCompletionsStreamEvent } from '@floway-dev/protocols/chat-completions'; +import { + defaultsForProvider, + getProviderRepo, + resolveEffectiveFlags, + type Provider, + type ProviderCallResult, + type ProviderInstance, + type ProviderResponsesResult, + type ProviderStreamResult, + type ResponsesAction, + type UpstreamCallOptions, + type UpstreamRecord, +} from '@floway-dev/provider'; + +const gatewayTimezone = (): string => Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC'; + +export const createCursorProvider = async (record: UpstreamRecord): Promise => { + assertCursorUpstreamRecord(record); + assertCursorUpstreamState(record.state); + const config: CursorUpstreamConfig = record.config; + // Always operates on the first account in the pool. The schema carries an + // array so a future fan-out can pick a different active account per call + // without a wire migration. + const accountIdentity = config.accounts[0]; + + const enabledFlags = resolveEffectiveFlags(defaultsForProvider('cursor'), [record.flagOverrides]); + + // Re-read upstream state on every request rather than capturing the record's + // state at construction. Refresh-token rotation, terminal-state transitions, + // and operator re-imports must all be visible to the next in-flight call. + const readActiveAccount = async () => { + const fresh = await getProviderRepo().upstreams.getById(record.id); + if (!fresh) throw new Error(`Cursor upstream ${record.id} disappeared mid-request`); + assertCursorUpstreamState(fresh.state); + const state = fresh.state; + const account = state.accounts.find(a => a.userId === accountIdentity.userId); + if (!account) { + throw new Error(`Cursor upstream ${record.id} state has no credential for account ${accountIdentity.userId}`); + } + return { state, account }; + }; + + const replaceActiveAccount = ( + state: CursorUpstreamState, + next: CursorUpstreamState['accounts'][number], + ): CursorUpstreamState => ({ + ...state, + accounts: state.accounts.map(a => (a.userId === next.userId ? next : a)), + }); + + const persistRefreshTokenRotation = async (newRefreshToken: string): Promise => { + const { state, account } = await readActiveAccount(); + const next = replaceActiveAccount(state, { + ...account, + refresh_token: newRefreshToken, + state_updated_at: new Date().toISOString(), + }); + await getProviderRepo().upstreams.saveState(record.id, next, { expectedState: state }); + }; + + const persistTerminalState = async ( + newState: 'session_terminated' | 'refresh_failed', + message: string, + ): Promise => { + const { state, account } = await readActiveAccount(); + const next = replaceActiveAccount(state, { + ...account, + state: newState, + state_message: message, + state_updated_at: new Date().toISOString(), + accessToken: null, + }); + await getProviderRepo().upstreams.saveState(record.id, next, { expectedState: state }); + }; + + const effects: CursorCallEffects = { persistRefreshTokenRotation, persistTerminalState }; + + const provider: ProviderInstance = { + getProvidedModels: async fetcher => { + // A model-list refresh is the first thing a brand-new Cursor upstream + // does, and it mints an access token. If the refresh_token has been + // revoked, flip the row to refresh_failed and rethrow. + let access; + try { + access = await ensureCursorAccessToken(record.id, accountIdentity.userId, refresh => + mintCursorAccessToken(refresh, fetcher, persistRefreshTokenRotation)); + } catch (err) { + if (err instanceof CursorSessionTerminatedError) { + await persistTerminalState('refresh_failed', err.upstreamMessage); + } + throw err; + } + const raw = await fetchCursorCatalog({ accessToken: access.token, timezone: gatewayTimezone(), fetcher, maxMode: config.maxMode }); + // Fresh state carries the per-model context windows observed on prior + // RunSSE turns; a read failure just falls back to the tooltip heuristic. + let observedState: CursorUpstreamState | null = null; + try { observedState = (await readActiveAccount()).state; } catch { observedState = null; } + const observedAt = Date.now(); + const maxMode = config.maxMode ?? false; + const models = raw.map(r => { + const model = cursorRawToProviderModel(r, enabledFlags); + // Prefer a real context window observed on the RunSSE stream + // (ConversationTokenDetails.maxTokens) over the tooltip-derived + // heuristic — it's the authoritative number for the active mode. + const observed = observedState ? readObservedContext(observedState, model.id, maxMode, observedAt) : null; + if (observed) model.limits = { ...model.limits, max_context_window_tokens: observed }; + return model; + }); + // Cursor Tab (StreamCpp) is exposed as an extra /v1/completions model. + if (config.tabCompletion?.enabled) models.push(cursorTabModel(enabledFlags)); + return models; + }, + + // Cursor bills as a flat-fee subscription; the dashboard reports notional + // cost per request as if paying the underlying model's public API rates. + getPricingForModelKey: pricingForCursorModelKey, + + callChatCompletions: async (model, body, signal, opts) => { + // Resolve the wire variant from the request's reasoning_effort before the + // interceptor chain strips it from the payload. + const wireModelId = resolveCursorWireModel(model, body.reasoning_effort); + const ctx: ChatCompletionsBoundaryCtx = { + payload: { ...body, model: model.id }, + headers: new Headers(opts.headers), + model, + }; + return await runInterceptors>( + ctx, + {}, + cursorChatCompletionsChain>(), + async () => { + const { account } = await readActiveAccount(); + const { model: _ignored, ...wireBody } = ctx.payload; + return await callCursorChatCompletions({ + upstreamId: record.id, + account, + model, + body: wireBody, + headers: ctx.headers, + signal, + effects, + call: opts, + maxMode: config.maxMode ?? false, + wireModelId, + // Absent config value = privacy on (safe default). Only the chat + // data plane honors this; model-catalog fetch stays always-private. + privacyMode: config.privacyMode ?? true, + }); + }, + ); + }, + + // Cursor upstream only exposes Chat Completions (RunSSE+BidiAppend); + // getProvidedModels advertises that single endpoint. Every other surface + // returns a 405 carrying a proper JSON error rather than a raw stack trace. + // The synthetic response still flows through the per-call latency recorder + // so the gateway's wrap-once contract holds. + callMessages: (_m, _b, _s, opts) => unsupportedStreamResult(opts), + callResponses: (_m, _b, action, _s, opts) => unsupportedResponsesResult(action, opts), + callMessagesCountTokens: (_m, _b, _s, opts) => unsupportedCallResult(opts), + // Cursor Tab (StreamCpp) bridged to OpenAI /v1/completions. A completion is + // never allowed to hard-fail an editor, so any error / non-clean edit + // yields an empty suggestion rather than a 5xx. + callCompletions: async (model, body, signal, opts) => { + if (!config.tabCompletion?.enabled) return await unsupportedCallResult(opts); + const zeroUsage: CompletionsUsage = { promptTokens: 0, completionTokens: 0 }; + const emptyResponse = () => new Response(completionsResponseBody(model.id, '', zeroUsage), { status: 200, headers: { 'content-type': 'application/json' } }); + // Static token estimator — StreamCpp returns no usage of its own, so we + // derive prompt tokens from the file we sent Cursor and completion + // tokens from the raw upstream text (pre-render). See + // `estimateCursorTabTokens` in completions.ts for the ratio calibration. + const usageOf = (request: StreamCppRequestInput, outputText: string): CompletionsUsage => ({ + promptTokens: estimateCursorTabTokens(request.contents, request.languageId), + completionTokens: estimateCursorTabTokens(outputText, request.languageId), + }); + const modelName = config.tabCompletion?.model ?? 'fast'; + const call = (async (): Promise => { + try { + const prompt = typeof body.prompt === 'string' ? body.prompt : ''; + const format = detectPromptFormat(prompt); + const withToken = async () => { + const access = await ensureCursorAccessToken(record.id, accountIdentity.userId, refresh => mintCursorAccessToken(refresh, opts.fetcher, persistRefreshTokenRotation)); + return { access, checksum: await generateCursorChecksum(access.token) }; + }; + + // Zeta 2.1 (V0318) marker path: reconstruct the editable region from + // the prompt, ask Cursor Tab to rewrite the file, and re-emit the + // rewritten region as a `<|marker_1|>…<|marker_K|>` span. Falls back + // to the FIM/plain insertion path for any unparseable prompt. + const parsed = format === 'zeta-v0318' ? parseZetaV0318(prompt) : null; + if (parsed) { + const { access, checksum } = await withToken(); + const request = streamCppInputForZeta(parsed, modelName); + const result = await callStreamCpp({ fetcher: opts.fetcher, accessToken: access.token, checksum, request, signal }); + const rendered = result.ok ? renderZetaV0318Output(parsed, result.rangeToReplace, result.text) : null; + return new Response(completionsResponseBody(model.id, rendered ?? '', usageOf(request, result.ok ? result.text : '')), { status: 200, headers: { 'content-type': 'application/json' } }); + } + + // Zeta V0615 hashed-regions path (custom clients only — not a Zed GUI + // option). Same shape as V0318 but the region is one file excerpt. + const parsed615 = format === 'zeta-v0615' ? parseZetaV0615(prompt) : null; + if (parsed615) { + const { access, checksum } = await withToken(); + const request = streamCppInputForV0615(parsed615, modelName); + const result = await callStreamCpp({ fetcher: opts.fetcher, accessToken: access.token, checksum, request, signal }); + const rendered = result.ok ? renderV0615Output(parsed615, result.rangeToReplace, result.text) : null; + return new Response(completionsResponseBody(model.id, rendered ?? '', usageOf(request, result.ok ? result.text : '')), { status: 200, headers: { 'content-type': 'application/json' } }); + } + + // FIM / plain-prompt insertion path. + const ps = parsePrefixSuffix(prompt, typeof body.suffix === 'string' ? body.suffix : undefined); + const request = streamCppInputForPrefixSuffix(ps, { relativePath: 'completion.txt', languageId: languageIdForCompletion(body), modelName }); + const { access, checksum } = await withToken(); + const result = await callStreamCpp({ fetcher: opts.fetcher, accessToken: access.token, checksum, request, signal }); + const insertion = result.ok ? applyStops(extractInsertion(ps, result.rangeToReplace, result.text), body.stop as string | string[] | undefined) : ''; + return new Response(completionsResponseBody(model.id, insertion, usageOf(request, result.ok ? result.text : '')), { status: 200, headers: { 'content-type': 'application/json' } }); + } catch { + return emptyResponse(); + } + })(); + return { response: await opts.recordUpstreamLatency(call), modelKey: model.id }; + }, + callEmbeddings: (_m, _b, _s, opts) => unsupportedCallResult(opts), + callImagesGenerations: (_m, _b, _s, opts) => unsupportedCallResult(opts), + callImagesEdits: (_m, _b, _s, opts) => unsupportedCallResult(opts), + }; + + return { + upstream: record.id, + kind: 'cursor', + name: record.name, + disabledPublicModelIds: record.disabledPublicModelIds, + modelPrefix: record.modelPrefix, + instance: provider, + supportsResponsesItemReference: false, + }; +}; + +const syntheticUnsupportedResponse = (): Response => + syntheticErrorResponse(405, 'method_not_allowed', 'Endpoint not supported by cursor provider'); + +const unsupportedStreamResult = async (opts: UpstreamCallOptions): Promise> => ({ + ok: false, + modelKey: '', + response: await opts.recordUpstreamLatency(Promise.resolve(syntheticUnsupportedResponse())), +}); + +const unsupportedCallResult = async (opts: UpstreamCallOptions): Promise => ({ + modelKey: '', + response: await opts.recordUpstreamLatency(Promise.resolve(syntheticUnsupportedResponse())), +}); + +const unsupportedResponsesResult = async (action: ResponsesAction, opts: UpstreamCallOptions): Promise => ({ + action, + ok: false, + modelKey: '', + response: await opts.recordUpstreamLatency(Promise.resolve(syntheticUnsupportedResponse())), +}); diff --git a/packages/provider-cursor/src/provider_test.ts b/packages/provider-cursor/src/provider_test.ts new file mode 100644 index 000000000..27f380521 --- /dev/null +++ b/packages/provider-cursor/src/provider_test.ts @@ -0,0 +1,54 @@ +import { describe, expect, test } from 'vitest'; + +import { createCursorProvider } from './provider.ts'; +import type { UpstreamRecord } from '@floway-dev/provider'; +import { noopUpstreamCallOptions, stubProviderModel } from '@floway-dev/test-utils'; + +const record: UpstreamRecord = { + id: 'up', + kind: 'cursor', + name: 'Cursor', + enabled: true, + sortOrder: 0, + createdAt: '2026-01-01T00:00:00.000Z', + updatedAt: '2026-01-01T00:00:00.000Z', + config: { accounts: [{ email: 'a@b.com', userId: 'u1' }] }, + state: { + accounts: [{ + userId: 'u1', + refresh_token: 'rt', + state: 'active', + state_updated_at: '2026-01-01T00:00:00Z', + accessToken: null, + }], + }, + flagOverrides: {}, + disabledPublicModelIds: [], + proxyFallbackList: [], + modelPrefix: null, +}; + +describe('createCursorProvider', () => { + test('returns a cursor provider instance', async () => { + const inst = await createCursorProvider(record); + expect(inst.kind).toBe('cursor'); + expect(inst.upstream).toBe('up'); + expect(inst.instance.getPricingForModelKey('composer-2.5')?.input).toBe(0.5); + }); + + test('unsupported surfaces return 405', async () => { + const inst = await createCursorProvider(record); + const model = stubProviderModel({ id: 'gpt-4o' }); + const opts = noopUpstreamCallOptions(); + + const messages = await inst.instance.callMessages(model, {} as never, undefined, opts); + expect(messages.ok).toBe(false); + if (!messages.ok) expect(messages.response.status).toBe(405); + + const embeddings = await inst.instance.callEmbeddings(model, {} as never, undefined, opts); + expect(embeddings.response.status).toBe(405); + + const completions = await inst.instance.callCompletions(model, {} as never, undefined, opts); + expect(completions.response.status).toBe(405); + }); +}); diff --git a/packages/provider-cursor/src/quota.ts b/packages/provider-cursor/src/quota.ts new file mode 100644 index 000000000..208885543 --- /dev/null +++ b/packages/provider-cursor/src/quota.ts @@ -0,0 +1,179 @@ +/** + * Cursor subscription usage fetch. + * + * `POST cursor.com/api/dashboard/get-current-period-usage` is the same + * endpoint the browser dashboard's Spending tab uses — it validates the + * WorkOS session cookie (`WorkosCursorSessionToken=${userId}::${jwt}`) and + * returns the current cycle's spend, per-bucket percentages, and the cycle + * end. Called on-demand by the control-plane cursor-quota route (mirrors the + * copilot-quota shape); result is not persisted. + */ + +import type { Fetcher } from '@floway-dev/provider'; + +// Subscription usage — cursor.com dashboard endpoint + +export const CURSOR_DASHBOARD_USAGE_URL = 'https://cursor.com/api/dashboard/get-current-period-usage'; + +// The dashboard endpoint rejects non-browser-shaped requests with +// `Invalid origin for state-changing request`. Origin + Referer must match a +// cursor.com surface; the User-Agent must look like a real browser. Kept here +// (not in constants.ts) so this endpoint's requirements stay next to the code +// that sends them. +const CURSOR_DASHBOARD_ORIGIN = 'https://cursor.com'; +const CURSOR_DASHBOARD_REFERER = 'https://cursor.com/dashboard/spending'; +const CURSOR_DASHBOARD_USER_AGENT = + 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/149.0.0.0 Safari/537.36'; + +/** + * One current-billing-cycle usage snapshot from cursor.com/dashboard/spending. + * + * `limitCents` is the plan ceiling. Cursor omits it for cycles with no plan + * assigned; the field is null in that case. + * + * The three `*PercentUsed` fields mirror the three bars the Cursor dashboard + * UI shows: `total` is the whole spend against the plan limit; `auto` is the + * flat-fee Auto + Composer bucket; `api` is the usage-based-pricing bucket. + * + * `billingCycleEndMs` is unix-ms of the reset time. Null if Cursor omits it. + */ +export interface CursorDashboardUsage { + limitCents: number | null; + totalSpendCents: number; + autoPercentUsed: number; + apiPercentUsed: number; + totalPercentUsed: number; + billingCycleEndMs: number | null; +} + +/** WorkOS session cookie rejected (3xx redirect to authkit, or 401/403). */ +export class CursorDashboardSessionExpiredError extends Error { + constructor(message: string) { + super(message); + this.name = 'CursorDashboardSessionExpiredError'; + } +} + +/** Any other non-2xx or transport failure. */ +export class CursorDashboardUpstreamError extends Error { + readonly status: number; + constructor(status: number, message: string) { + super(message); + this.name = 'CursorDashboardUpstreamError'; + this.status = status; + } +} + +export interface FetchCursorDashboardUsageOptions { + userId: string; + accessToken: string; + fetcher: Fetcher; +} + +// Coerce Cursor's mixed number-or-string cents/ms fields into a finite number, +// or fall back if the value is missing/garbage. +const toFiniteNumber = (raw: unknown, fallback: number): number => { + if (typeof raw === 'number' && Number.isFinite(raw)) return raw; + if (typeof raw === 'string' && raw !== '') { + const parsed = Number(raw); + if (Number.isFinite(parsed)) return parsed; + } + return fallback; +}; + +const clampPercent = (raw: unknown): number => { + const n = toFiniteNumber(raw, 0); + if (n < 0) return 0; + if (n > 100) return 100; + return n; +}; + +const isRecord = (value: unknown): value is Record => + typeof value === 'object' && value !== null && !Array.isArray(value); + +/** + * Fetch current-cycle usage from the Cursor dashboard. Never persists. + * + * Session-expired detection: cursor.com redirects to the WorkOS authkit login + * page when the session cookie is rejected, and `redirect: 'manual'` surfaces + * that as a 3xx status the caller can distinguish from a genuine upstream 5xx. + */ +export const fetchCursorDashboardUsage = async ( + opts: FetchCursorDashboardUsageOptions, +): Promise => { + let response: Response; + try { + response = await opts.fetcher(CURSOR_DASHBOARD_USAGE_URL, { + method: 'POST', + redirect: 'manual', + headers: { + Cookie: `WorkosCursorSessionToken=${opts.userId}::${opts.accessToken}`, + Origin: CURSOR_DASHBOARD_ORIGIN, + Referer: CURSOR_DASHBOARD_REFERER, + 'Content-Type': 'application/json', + Accept: 'application/json', + 'User-Agent': CURSOR_DASHBOARD_USER_AGENT, + }, + body: '{}', + }); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + throw new CursorDashboardUpstreamError(0, `Cursor dashboard fetch failed: ${message}`); + } + + if (response.status >= 300 && response.status < 400) { + throw new CursorDashboardSessionExpiredError( + 'Cursor dashboard redirected to authkit — session cookie rejected. Re-import the credential to recover.', + ); + } + + if (response.status === 401 || response.status === 403) { + throw new CursorDashboardSessionExpiredError( + `Cursor dashboard rejected the session (HTTP ${response.status}). Re-import the credential to recover.`, + ); + } + + if (!response.ok) { + const bodyText = (await response.text().catch(() => '')).slice(0, 200); + throw new CursorDashboardUpstreamError( + response.status, + `Cursor dashboard returned HTTP ${response.status}: ${bodyText}`, + ); + } + + let payload: unknown; + try { + payload = await response.json(); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + throw new CursorDashboardUpstreamError(response.status, `Cursor dashboard returned non-JSON body: ${message}`); + } + + if (!isRecord(payload)) { + throw new CursorDashboardUpstreamError(response.status, 'Cursor dashboard returned non-object body'); + } + + const planUsage = isRecord(payload.planUsage) ? payload.planUsage : {}; + + // Cycles with no plan usage have `planUsage: {}` — surface all-zero rather + // than throw, so the UI can show "no spend this cycle" with reset time. + const hasPlan = Object.keys(planUsage).length > 0; + const limitRaw = planUsage.limit; + const limitCents = hasPlan && (typeof limitRaw === 'number' || typeof limitRaw === 'string') + ? Math.max(0, toFiniteNumber(limitRaw, 0)) + : null; + + const billingCycleEndRaw = payload.billingCycleEnd; + const billingCycleEndMs = typeof billingCycleEndRaw === 'number' || typeof billingCycleEndRaw === 'string' + ? toFiniteNumber(billingCycleEndRaw, 0) || null + : null; + + return { + limitCents, + totalSpendCents: Math.max(0, toFiniteNumber(planUsage.totalSpend, 0)), + autoPercentUsed: clampPercent(planUsage.autoPercentUsed), + apiPercentUsed: clampPercent(planUsage.apiPercentUsed), + totalPercentUsed: clampPercent(planUsage.totalPercentUsed), + billingCycleEndMs, + }; +}; diff --git a/packages/provider-cursor/src/quota_test.ts b/packages/provider-cursor/src/quota_test.ts new file mode 100644 index 000000000..214bfce96 --- /dev/null +++ b/packages/provider-cursor/src/quota_test.ts @@ -0,0 +1,146 @@ +import { describe, expect, test } from 'vitest'; + +import { + CURSOR_DASHBOARD_USAGE_URL, + CursorDashboardSessionExpiredError, + CursorDashboardUpstreamError, + fetchCursorDashboardUsage, +} from './quota.ts'; +import type { Fetcher } from '@floway-dev/provider'; + +const jsonResponse = (status: number, body: unknown): Response => + new Response(JSON.stringify(body), { status, headers: { 'content-type': 'application/json' } }); + +const captureFetcher = (respond: (url: string, init: RequestInit) => Response | Promise) => { + const calls: Array<{ url: string; init: RequestInit }> = []; + const fetcher: Fetcher = async (url, init) => { + calls.push({ url, init }); + return await respond(url, init); + }; + return { fetcher, calls }; +}; + +describe('fetchCursorDashboardUsage', () => { + const validPlanUsage = { + limit: 2000, // $20.00 + totalSpend: 512, + autoPercentUsed: 12.5, + apiPercentUsed: 87.5, + totalPercentUsed: 25.6, + }; + const validBody = { planUsage: validPlanUsage, billingCycleEnd: '1738000000000' }; + + test('sends WorkOS cookie + browser-shaped headers to the dashboard URL', async () => { + const { fetcher, calls } = captureFetcher(() => jsonResponse(200, validBody)); + await fetchCursorDashboardUsage({ userId: 'user_abc', accessToken: 'jwt.xyz', fetcher }); + expect(calls).toHaveLength(1); + expect(calls[0]!.url).toBe(CURSOR_DASHBOARD_USAGE_URL); + expect(calls[0]!.init.method).toBe('POST'); + expect(calls[0]!.init.redirect).toBe('manual'); + expect(calls[0]!.init.body).toBe('{}'); + const headers = calls[0]!.init.headers as Record; + expect(headers.Cookie).toBe('WorkosCursorSessionToken=user_abc::jwt.xyz'); + expect(headers.Origin).toBe('https://cursor.com'); + expect(headers.Referer).toBe('https://cursor.com/dashboard/spending'); + expect(headers['Content-Type']).toBe('application/json'); + expect(headers['User-Agent']).toMatch(/^Mozilla\/5\.0 .*Chrome\//); + }); + + test('parses the happy-path body into typed cents / percent fields', async () => { + const { fetcher } = captureFetcher(() => jsonResponse(200, validBody)); + const usage = await fetchCursorDashboardUsage({ userId: 'u', accessToken: 't', fetcher }); + expect(usage).toEqual({ + limitCents: 2000, + totalSpendCents: 512, + autoPercentUsed: 12.5, + apiPercentUsed: 87.5, + totalPercentUsed: 25.6, + billingCycleEndMs: 1738000000000, + }); + }); + + test('accepts numeric billingCycleEnd', async () => { + const { fetcher } = captureFetcher(() => + jsonResponse(200, { planUsage: validPlanUsage, billingCycleEnd: 1738000000000 })); + const usage = await fetchCursorDashboardUsage({ userId: 'u', accessToken: 't', fetcher }); + expect(usage.billingCycleEndMs).toBe(1738000000000); + }); + + test('clamps percentages into [0, 100]', async () => { + const { fetcher } = captureFetcher(() => + jsonResponse(200, { + planUsage: { limit: 100, totalSpend: 200, autoPercentUsed: -5, apiPercentUsed: 150, totalPercentUsed: 200 }, + billingCycleEnd: '0', + })); + const usage = await fetchCursorDashboardUsage({ userId: 'u', accessToken: 't', fetcher }); + expect(usage.autoPercentUsed).toBe(0); + expect(usage.apiPercentUsed).toBe(100); + expect(usage.totalPercentUsed).toBe(100); + }); + + test('coerces string cents into numbers and floors negatives at 0', async () => { + const { fetcher } = captureFetcher(() => + jsonResponse(200, { + planUsage: { limit: '2500', totalSpend: '-10', autoPercentUsed: '3.2', apiPercentUsed: 0, totalPercentUsed: 0 }, + billingCycleEnd: '1', + })); + const usage = await fetchCursorDashboardUsage({ userId: 'u', accessToken: 't', fetcher }); + expect(usage.limitCents).toBe(2500); + expect(usage.totalSpendCents).toBe(0); + expect(usage.autoPercentUsed).toBe(3.2); + }); + + test('empty planUsage surfaces null limit + zero spend but keeps cycle end', async () => { + const { fetcher } = captureFetcher(() => + jsonResponse(200, { planUsage: {}, billingCycleEnd: '1738000000000' })); + const usage = await fetchCursorDashboardUsage({ userId: 'u', accessToken: 't', fetcher }); + expect(usage.limitCents).toBeNull(); + expect(usage.totalSpendCents).toBe(0); + expect(usage.totalPercentUsed).toBe(0); + expect(usage.billingCycleEndMs).toBe(1738000000000); + }); + + test('null billingCycleEnd is tolerated', async () => { + const { fetcher } = captureFetcher(() => + jsonResponse(200, { planUsage: validPlanUsage, billingCycleEnd: null })); + const usage = await fetchCursorDashboardUsage({ userId: 'u', accessToken: 't', fetcher }); + expect(usage.billingCycleEndMs).toBeNull(); + }); + + test.each([301, 302, 303, 307])('%d redirect maps to CursorDashboardSessionExpiredError', async status => { + const { fetcher } = captureFetcher(() => new Response('', { status, headers: { location: 'https://authkit.cursor.com/sign-in' } })); + await expect(fetchCursorDashboardUsage({ userId: 'u', accessToken: 't', fetcher })) + .rejects.toBeInstanceOf(CursorDashboardSessionExpiredError); + }); + + test.each([401, 403])('%d maps to CursorDashboardSessionExpiredError', async status => { + const { fetcher } = captureFetcher(() => new Response('nope', { status })); + await expect(fetchCursorDashboardUsage({ userId: 'u', accessToken: 't', fetcher })) + .rejects.toBeInstanceOf(CursorDashboardSessionExpiredError); + }); + + test('other non-2xx maps to CursorDashboardUpstreamError with status', async () => { + const { fetcher } = captureFetcher(() => new Response('oops', { status: 500 })); + await expect(fetchCursorDashboardUsage({ userId: 'u', accessToken: 't', fetcher })) + .rejects.toMatchObject({ name: 'CursorDashboardUpstreamError', status: 500 }); + }); + + test('transport failure maps to CursorDashboardUpstreamError with status 0', async () => { + const fetcher: Fetcher = async () => { throw new Error('econnreset'); }; + await expect(fetchCursorDashboardUsage({ userId: 'u', accessToken: 't', fetcher })) + .rejects.toMatchObject({ name: 'CursorDashboardUpstreamError', status: 0 }); + }); + + test('non-JSON body maps to CursorDashboardUpstreamError', async () => { + const { fetcher } = captureFetcher(() => + new Response('...', { status: 200, headers: { 'content-type': 'text/html' } })); + await expect(fetchCursorDashboardUsage({ userId: 'u', accessToken: 't', fetcher })) + .rejects.toBeInstanceOf(CursorDashboardUpstreamError); + }); + + test('non-object JSON body maps to CursorDashboardUpstreamError', async () => { + const { fetcher } = captureFetcher(() => jsonResponse(200, [1, 2, 3])); + await expect(fetchCursorDashboardUsage({ userId: 'u', accessToken: 't', fetcher })) + .rejects.toBeInstanceOf(CursorDashboardUpstreamError); + }); +}); diff --git a/packages/provider-cursor/src/session-id.ts b/packages/provider-cursor/src/session-id.ts new file mode 100644 index 000000000..4657568d0 --- /dev/null +++ b/packages/provider-cursor/src/session-id.ts @@ -0,0 +1,107 @@ +/** + * Cursor session ID derivation. + * + * Determines a sessionKey for DurableHttpSession.acquire() so consecutive + * tool-call turns reuse the same RunSSE stream. Uses the opencode-cursor-proxy + * pattern: encode the sessionId into tool_call_id itself, so the OpenAI + * protocol's mandatory id-echo becomes the session correlation signal. + * + * sessionKey format: `cursor:${upstreamId}:${apiKeyId}:${form}:${id}` + * - upstreamId: physical isolation (different cursor accounts) + * - apiKeyId: security isolation (same account, different callers) + * - form: 'hdr' (explicit header) | 'auto' (minted/recovered from tool_call_id) + * - id: the session identifier itself + */ + +import type { ChatCompletionsPayload } from '@floway-dev/protocols/chat-completions'; + +const TOOL_CALL_PREFIX_RE = /^sess_([a-zA-Z0-9]+)__/; + +export interface DeriveSessionKeyResult { + /** null = no session correlation found; caller must mint a new key. */ + sessionKey: string | null; + /** true = client is following up on a prior tool_call turn. */ + isFollowUp: boolean; +} + +/** + * Derive a sessionKey from the inbound request. + * + * Priority: + * 1. Explicit `X-Floway-Conversation-Id` header (smart clients) + * 2. `sess___` prefix parsed from tool_call_id in history + * 3. null → caller must mint a new key + */ +export function deriveSessionKey( + upstreamId: string, + apiKeyId: string, + headers: Headers, + messages: ChatCompletionsPayload['messages'], +): DeriveSessionKeyResult { + const scope = `cursor:${upstreamId}:${apiKeyId}`; + + const explicit = headers.get('x-floway-conversation-id'); + if (explicit) { + return { sessionKey: `${scope}:hdr:${explicit}`, isFollowUp: true }; + } + + for (let i = messages.length - 1; i >= 0; i--) { + const msg = messages[i]!; + if (msg.role === 'tool' && msg.tool_call_id) { + const m = TOOL_CALL_PREFIX_RE.exec(msg.tool_call_id); + if (m) return { sessionKey: `${scope}:auto:${m[1]}`, isFollowUp: true }; + } + if (msg.role === 'assistant' && msg.tool_calls) { + for (const tc of msg.tool_calls) { + const m = TOOL_CALL_PREFIX_RE.exec(tc.id); + if (m) return { sessionKey: `${scope}:auto:${m[1]}`, isFollowUp: true }; + } + } + } + + return { sessionKey: null, isFollowUp: false }; +} + +/** Mint a fresh sessionKey (new conversation). */ +export function mintSessionKey(upstreamId: string, apiKeyId: string): string { + const id = crypto.randomUUID().replace(/-/g, '').slice(0, 12); + return `cursor:${upstreamId}:${apiKeyId}:auto:${id}`; +} + +/** Extract the bare session id from a full sessionKey (last segment). */ +function bareId(sessionKey: string): string { + return sessionKey.split(':').pop()!; +} + +/** The cursor exec identity needed to build an ExecMcpResult on a follow-up. */ +export interface ExecRef { + /** ExecClientMessage.id (field 1). */ + id: number; + /** exec_id (field 15); may be absent. */ + execId: string | undefined; +} + +/** + * Wrap: encode the session id AND the cursor exec identity (id + exec_id) into + * the OpenAI-facing tool_call_id. The OpenAI protocol echoes tool_call_id back + * verbatim on the follow-up tool message, so the follow-up — possibly on a + * different instance — can rebuild the ExecMcpResult straight from the client's + * id, with no server-side pending-exec map. `__` is the delimiter; cursor ids + * use single underscores only (`call_…`, `fc_…`). + */ +export function wrapToolCallId(sessionKey: string, exec: ExecRef): string { + return `sess_${bareId(sessionKey)}__${exec.id}__${exec.execId ?? ''}`; +} + +/** Decode the cursor exec identity from a wrapped tool_call_id, or null. */ +export function decodeToolCallId(wrapped: string): ExecRef | null { + const m = TOOL_CALL_PREFIX_RE.exec(wrapped); + if (!m) return null; + const rest = wrapped.slice(m[0].length); // `${id}__${execId}` + const sep = rest.indexOf('__'); + const idStr = sep === -1 ? rest : rest.slice(0, sep); + const execIdStr = sep === -1 ? '' : rest.slice(sep + 2); + const id = Number(idStr); + if (!Number.isInteger(id)) return null; + return { id, execId: execIdStr === '' ? undefined : execIdStr }; +} diff --git a/packages/provider-cursor/src/session-id_test.ts b/packages/provider-cursor/src/session-id_test.ts new file mode 100644 index 000000000..d8b2f1f37 --- /dev/null +++ b/packages/provider-cursor/src/session-id_test.ts @@ -0,0 +1,94 @@ +import { describe, expect, test } from 'vitest'; + +import { deriveSessionKey, mintSessionKey, wrapToolCallId, decodeToolCallId } from './session-id.ts'; + +describe('deriveSessionKey', () => { + test('priority 1: X-Floway-Conversation-Id header wins', () => { + const headers = new Headers({ 'x-floway-conversation-id': 'conv-123' }); + const result = deriveSessionKey('up1', 'ak1', headers, [{ role: 'user', content: 'hi' }]); + expect(result.sessionKey).toBe('cursor:up1:ak1:hdr:conv-123'); + expect(result.isFollowUp).toBe(true); + }); + + test('priority 2: tool_call_id with sess_ prefix in role:tool', () => { + const result = deriveSessionKey('up1', 'ak1', new Headers(), [ + { role: 'user', content: 'hi' }, + { role: 'assistant', content: null, tool_calls: [{ id: 'sess_abc123def456__call_xyz', type: 'function', function: { name: 'f', arguments: '{}' } }] }, + { role: 'tool', tool_call_id: 'sess_abc123def456__call_xyz', content: '{"result":1}' }, + ]); + expect(result.sessionKey).toBe('cursor:up1:ak1:auto:abc123def456'); + expect(result.isFollowUp).toBe(true); + }); + + test('priority 2: assistant tool_calls with sess_ prefix (no tool message yet)', () => { + const result = deriveSessionKey('up1', 'ak1', new Headers(), [ + { role: 'user', content: 'hi' }, + { role: 'assistant', content: null, tool_calls: [{ id: 'sess_aabbccdd__call_99', type: 'function', function: { name: 'g', arguments: '{}' } }] }, + ]); + expect(result.sessionKey).toBe('cursor:up1:ak1:auto:aabbccdd'); + expect(result.isFollowUp).toBe(true); + }); + + test('priority 3: returns null when no correlation found', () => { + const result = deriveSessionKey('up1', 'ak1', new Headers(), [ + { role: 'user', content: 'What is 2+2?' }, + ]); + expect(result.sessionKey).toBeNull(); + expect(result.isFollowUp).toBe(false); + }); + + test('scans from the end (latest message wins)', () => { + const result = deriveSessionKey('up1', 'ak1', new Headers(), [ + { role: 'tool', tool_call_id: 'sess_old__call_1', content: 'x' }, + { role: 'user', content: 'new turn' }, + { role: 'assistant', content: null, tool_calls: [{ id: 'sess_new__call_2', type: 'function', function: { name: 'f', arguments: '{}' } }] }, + { role: 'tool', tool_call_id: 'sess_new__call_2', content: 'y' }, + ]); + expect(result.sessionKey).toBe('cursor:up1:ak1:auto:new'); + }); + + test('ignores tool_call_ids without the sess_ prefix', () => { + const result = deriveSessionKey('up1', 'ak1', new Headers(), [ + { role: 'assistant', content: null, tool_calls: [{ id: 'call_plain', type: 'function', function: { name: 'f', arguments: '{}' } }] }, + { role: 'tool', tool_call_id: 'call_plain', content: 'z' }, + ]); + expect(result.sessionKey).toBeNull(); + expect(result.isFollowUp).toBe(false); + }); +}); + +describe('mintSessionKey', () => { + test('produces the expected format', () => { + const key = mintSessionKey('up1', 'ak1'); + expect(key).toMatch(/^cursor:up1:ak1:auto:[a-f0-9]{12}$/); + }); + + test('each call produces a unique key', () => { + const a = mintSessionKey('up1', 'ak1'); + const b = mintSessionKey('up1', 'ak1'); + expect(a).not.toBe(b); + }); +}); + +describe('wrapToolCallId / decodeToolCallId', () => { + test('encodes the session id + exec ref and round-trips', () => { + const sessionKey = 'cursor:up1:ak1:auto:abc123def456'; + const wrapped = wrapToolCallId(sessionKey, { id: 7, execId: 'fc_01c10a8' }); + expect(wrapped).toBe('sess_abc123def456__7__fc_01c10a8'); + // deriveSessionKey still recovers the session id from the prefix. + expect(deriveSessionKey('up1', 'ak1', new Headers(), [{ role: 'tool', tool_call_id: wrapped, content: 'x' }]).sessionKey) + .toBe('cursor:up1:ak1:auto:abc123def456'); + expect(decodeToolCallId(wrapped)).toEqual({ id: 7, execId: 'fc_01c10a8' }); + }); + + test('round-trips an absent execId', () => { + const wrapped = wrapToolCallId('cursor:up1:ak1:auto:aabb', { id: 3, execId: undefined }); + expect(wrapped).toBe('sess_aabb__3__'); + expect(decodeToolCallId(wrapped)).toEqual({ id: 3, execId: undefined }); + }); + + test('decode returns null for ids without the sess_ prefix or a non-numeric id', () => { + expect(decodeToolCallId('call_plain')).toBeNull(); + expect(decodeToolCallId('sess_aabb__notanumber__fc_1')).toBeNull(); + }); +}); diff --git a/packages/provider-cursor/src/state.ts b/packages/provider-cursor/src/state.ts new file mode 100644 index 000000000..b8c463696 --- /dev/null +++ b/packages/provider-cursor/src/state.ts @@ -0,0 +1,171 @@ +// Gateway-managed Cursor credential state, persisted in upstreams.state_json. +// Writes happen via UpstreamRepo.saveState with optimistic concurrency keyed +// on the prior state JSON. + +export type CursorCredentialHealth = 'active' | 'session_terminated' | 'refresh_failed'; + +// Short-lived OAuth access token minted by exchanging the stored refresh_token +// against /auth/exchange_user_api_key. The refresh_token itself stays on +// CursorAccountCredential so a KV/cache wipe never forces operator re-import. +export interface CursorAccessTokenEntry { + token: string; + expiresAt: number; // unix ms + refreshedAt: string; // ISO 8601 +} + +// One account's autonomous credential state, joined back to its identity in +// CursorUpstreamConfig.accounts via `userId`. +export interface CursorAccountCredential { + userId: string; + // Cursor may rotate refresh_token on /auth/exchange_user_api_key. Stored in + // D1 (not KV) so KV eviction never forces operator re-import. + refresh_token: string; + state: CursorCredentialHealth; + state_message?: string; + // ISO 8601, written on every state transition. + state_updated_at: string; + accessToken: CursorAccessTokenEntry | null; +} + +export interface CursorUpstreamState { + accounts: CursorAccountCredential[]; + // Observed per-model context windows recovered from the RunSSE stream's + // ConversationTokenDetails.maxTokens, keyed by `${maxMode ? 'max' : 'norm'}:${modelId}` + // so the two Max-Mode variants of a model cache separately. Best-effort and + // TTL-bounded (see context-window.ts): the catalog prefers a fresh entry over + // the tooltip-derived heuristic. Absent on legacy rows. + modelContext?: Record; +} + +// One observed context window: the model's maxTokens and when it was seen. +export interface CursorModelContextEntry { + maxTokens: number; + at: number; // unix ms +} + +const ALLOWED_CREDENTIAL_KEYS_MAP: Record = { + userId: true, + refresh_token: true, + state: true, + state_message: true, + state_updated_at: true, + accessToken: true, +}; + +const ALLOWED_STATE_KEYS_MAP: Record = { + accounts: true, + modelContext: true, +}; + +const ALLOWED_ACCESS_TOKEN_KEYS_MAP: Record = { + token: true, + expiresAt: true, + refreshedAt: true, +}; + +const assertCursorAccessTokenEntry = (value: unknown, where: string): void => { + if (typeof value !== 'object' || value === null || Array.isArray(value)) { + throw new TypeError(`${where} must be a plain object`); + } + const obj = value as Record; + for (const key of Object.keys(obj)) { + if (!(key in ALLOWED_ACCESS_TOKEN_KEYS_MAP)) { + throw new TypeError(`${where} has unexpected key '${key}'`); + } + } + if (typeof obj.token !== 'string' || obj.token === '') { + throw new TypeError(`${where}.token must be a non-empty string`); + } + if (typeof obj.expiresAt !== 'number' || !Number.isFinite(obj.expiresAt)) { + throw new TypeError(`${where}.expiresAt must be a finite number`); + } + if (typeof obj.refreshedAt !== 'string' || obj.refreshedAt === '') { + throw new TypeError(`${where}.refreshedAt must be a non-empty string`); + } +}; + +const assertCursorAccountCredential = (value: unknown, where: string): void => { + if (typeof value !== 'object' || value === null || Array.isArray(value)) { + throw new TypeError(`${where} must be a plain object`); + } + const obj = value as Record; + for (const key of Object.keys(obj)) { + if (!(key in ALLOWED_CREDENTIAL_KEYS_MAP)) { + throw new TypeError(`${where} has unexpected key '${key}'`); + } + } + if (typeof obj.userId !== 'string' || obj.userId === '') { + throw new TypeError(`${where}.userId must be a non-empty string`); + } + if (typeof obj.refresh_token !== 'string' || obj.refresh_token === '') { + throw new TypeError(`${where}.refresh_token must be a non-empty string`); + } + if (obj.state !== 'active' && obj.state !== 'session_terminated' && obj.state !== 'refresh_failed') { + throw new TypeError(`${where}.state must be one of 'active' | 'session_terminated' | 'refresh_failed', got ${String(obj.state)}`); + } + if (obj.state_message !== undefined && typeof obj.state_message !== 'string') { + throw new TypeError(`${where}.state_message must be a string when present`); + } + if (typeof obj.state_updated_at !== 'string' || obj.state_updated_at === '') { + throw new TypeError(`${where}.state_updated_at must be a non-empty ISO string`); + } + if (obj.accessToken !== undefined && obj.accessToken !== null) { + assertCursorAccessTokenEntry(obj.accessToken, `${where}.accessToken`); + } +}; + +const assertCursorModelContext = (value: unknown, where: string): void => { + if (typeof value !== 'object' || value === null || Array.isArray(value)) { + throw new TypeError(`${where} must be a plain object`); + } + for (const [modelKey, entry] of Object.entries(value as Record)) { + if (typeof entry !== 'object' || entry === null || Array.isArray(entry)) { + throw new TypeError(`${where}['${modelKey}'] must be a plain object`); + } + const obj = entry as Record; + if (typeof obj.maxTokens !== 'number' || !Number.isFinite(obj.maxTokens)) { + throw new TypeError(`${where}['${modelKey}'].maxTokens must be a finite number`); + } + if (typeof obj.at !== 'number' || !Number.isFinite(obj.at)) { + throw new TypeError(`${where}['${modelKey}'].at must be a finite number`); + } + } +}; + +export function assertCursorUpstreamState(value: unknown): asserts value is CursorUpstreamState { + if (typeof value !== 'object' || value === null || Array.isArray(value)) { + throw new TypeError('CursorUpstreamState must be a plain object'); + } + const obj = value as Record; + for (const key of Object.keys(obj)) { + if (!(key in ALLOWED_STATE_KEYS_MAP)) { + throw new TypeError(`CursorUpstreamState has unexpected key '${key}'`); + } + } + if (!Array.isArray(obj.accounts)) { + throw new TypeError('CursorUpstreamState.accounts must be an array'); + } + if (obj.accounts.length !== 1) { + throw new TypeError(`CursorUpstreamState.accounts must hold exactly one account (got ${obj.accounts.length})`); + } + for (let i = 0; i < obj.accounts.length; i++) { + assertCursorAccountCredential(obj.accounts[i], `CursorUpstreamState.accounts[${i}]`); + } + if (obj.modelContext !== undefined) { + assertCursorModelContext(obj.modelContext, 'CursorUpstreamState.modelContext'); + } +} + +// Boundary normalization: legacy rows may carry no accessToken key; the typed +// contract promises null rather than undefined. Shallow copy with absent → +// null; the original raw is left untouched for CAS expectedState. +export const readCursorUpstreamState = (raw: unknown): CursorUpstreamState => { + assertCursorUpstreamState(raw); + return { + ...raw, + accounts: raw.accounts.map(account => ({ + ...account, + accessToken: account.accessToken ?? null, + })), + }; +}; diff --git a/packages/provider-cursor/src/state_test.ts b/packages/provider-cursor/src/state_test.ts new file mode 100644 index 000000000..e09010f7c --- /dev/null +++ b/packages/provider-cursor/src/state_test.ts @@ -0,0 +1,59 @@ +import { describe, expect, test } from 'vitest'; + +import { assertCursorUpstreamState, readCursorUpstreamState } from './state.ts'; + +const baseCred = { + userId: 'u1', + refresh_token: 'ref', + state: 'active' as const, + state_updated_at: '2026-01-01T00:00:00Z', + accessToken: { token: 'tok', expiresAt: 9999999999999, refreshedAt: '2026-01-01T00:00:00Z' }, +}; + +describe('assertCursorUpstreamState', () => { + test('accepts a complete single-account state', () => { + expect(() => assertCursorUpstreamState({ accounts: [baseCred] })).not.toThrow(); + }); + + test('accepts a modelContext map of observed context windows', () => { + expect(() => assertCursorUpstreamState({ + accounts: [baseCred], + modelContext: { 'norm:claude-opus-4-8': { maxTokens: 200000, at: 123 }, 'max:claude-opus-4-8': { maxTokens: 1000000, at: 456 } }, + })).not.toThrow(); + }); + + test.each([ + ['accounts not an array', { accounts: baseCred }], + ['empty accounts', { accounts: [] }], + ['two accounts', { accounts: [baseCred, { ...baseCred, userId: 'u2' }] }], + ['extra top-level key', { accounts: [baseCred], extra: 1 }], + ['missing userId', { accounts: [{ ...baseCred, userId: '' }] }], + ['missing refresh_token', { accounts: [{ ...baseCred, refresh_token: '' }] }], + ['bad state', { accounts: [{ ...baseCred, state: 'weird' }] }], + ['missing state_updated_at', { accounts: [{ ...baseCred, state_updated_at: '' }] }], + ['bad accessToken', { accounts: [{ ...baseCred, accessToken: { token: '', expiresAt: 1, refreshedAt: 'x' } }] }], + ['modelContext entry missing maxTokens', { accounts: [baseCred], modelContext: { 'norm:m': { at: 1 } } }], + ['modelContext entry with non-number at', { accounts: [baseCred], modelContext: { 'norm:m': { maxTokens: 1, at: 'x' } } }], + ])('rejects %s', (_label, value) => { + expect(() => assertCursorUpstreamState(value)).toThrow(); + }); +}); + +describe('readCursorUpstreamState', () => { + test('normalizes absent accessToken to null', () => { + const legacy = { + accounts: [ + { + userId: 'u1', + refresh_token: 'ref', + state: 'active', + state_updated_at: '2026-01-01T00:00:00Z', + }, + ], + }; + const read = readCursorUpstreamState(legacy); + expect(read.accounts[0]!.accessToken).toBeNull(); + // original untouched + expect((legacy.accounts[0] as Record)['accessToken']).toBeUndefined(); + }); +}); diff --git a/packages/provider-cursor/src/stream-cpp-transport.ts b/packages/provider-cursor/src/stream-cpp-transport.ts new file mode 100644 index 000000000..8286cff0c --- /dev/null +++ b/packages/provider-cursor/src/stream-cpp-transport.ts @@ -0,0 +1,110 @@ +/** + * Cursor StreamCpp (Tab) transport: a Connect proto server-stream call. + * + * Unlike RunSSE (dual-channel via DurableHttpSession), StreamCpp is a plain + * unary-request → server-stream: POST one enveloped proto frame, then read + * enveloped response frames off the response body until the trailer. Runs over + * the per-request proxy-aware `fetcher`; no persistent session needed. The Tab + * backend is a separate, geo-routed host and expects client-type `ide`. + */ + +import { CURSOR_GCPP_BACKEND_BASE, CURSOR_STREAM_CPP_PATH, CURSOR_TAB_CLIENT_VERSION, CURSOR_USER_AGENT } from './constants.ts'; +import { TEXT_DECODER } from './proto/decoding.ts'; +import { addConnectEnvelope, decompressGzip, FLAG_END_STREAM, isCompressedFrame, readConnectFrame } from './proto/envelope.ts'; +import { decodeStreamCppResponse, encodeStreamCppRequest, type StreamCppLineRange, type StreamCppRequestInput } from './proto/stream-cpp.ts'; +import type { Fetcher } from '@floway-dev/provider'; + +export interface StreamCppCallResult { + ok: boolean; + status: number; + /** Accumulated completion text across data frames. */ + text: string; + /** Line range the edit replaces, from the last frame that carried one. */ + rangeToReplace?: StreamCppLineRange; + /** Trailer / error body when the call did not produce a clean completion. */ + errorBody?: string; +} + +export const callStreamCpp = async (opts: { + fetcher: Fetcher; + accessToken: string; + checksum: string; + request: StreamCppRequestInput; + signal?: AbortSignal; +}): Promise => { + const body = addConnectEnvelope(encodeStreamCppRequest(opts.request)); + const response = await opts.fetcher(`${CURSOR_GCPP_BACKEND_BASE}${CURSOR_STREAM_CPP_PATH}`, { + method: 'POST', + headers: { + authorization: `Bearer ${opts.accessToken}`, + 'content-type': 'application/connect+proto', + 'connect-protocol-version': '1', + 'user-agent': CURSOR_USER_AGENT, + 'x-cursor-checksum': opts.checksum, + 'x-cursor-client-version': CURSOR_TAB_CLIENT_VERSION, + 'x-cursor-client-type': 'ide', + 'x-cursor-streaming': 'true', + 'x-request-id': crypto.randomUUID(), + 'x-session-id': crypto.randomUUID(), + 'x-ghost-mode': 'true', + }, + body: body as BodyInit, + signal: opts.signal, + }); + + if (!response.ok || !response.body) { + const errorBody = await response.text().catch(() => ''); + return { ok: false, status: response.status, text: '', errorBody }; + } + + const reader = response.body.getReader(); + let buffer = new Uint8Array(0); + let offset = 0; + let text = ''; + let rangeToReplace: StreamCppLineRange | undefined; + let endStream: string | undefined; + + for (;;) { + const { done, value } = await reader.read(); + if (done) break; + if (value && value.length > 0) { + const tailLen = buffer.length - offset; + if (tailLen === 0) { + buffer = value; + } else { + const next = new Uint8Array(tailLen + value.length); + next.set(buffer.subarray(offset), 0); + next.set(value, tailLen); + buffer = next; + } + offset = 0; + } + for (;;) { + const frame = readConnectFrame(buffer, offset); + if (!frame) break; + offset = frame.nextOffset; + const payload = isCompressedFrame(frame.flags) ? await decompressGzip(frame.payload) : frame.payload; + // Connect server-stream ends with a FLAG_END_STREAM (0x02) frame whose + // payload is the EndStreamResponse JSON ({} on success, {"error":…} on + // failure) — not a gRPC-web 0x80 trailer. + if ((frame.flags & FLAG_END_STREAM) !== 0) { + endStream = TEXT_DECODER.decode(payload); + continue; + } + const decoded = decodeStreamCppResponse(payload); + if (decoded.text) text += decoded.text; + if (decoded.rangeToReplace) rangeToReplace = decoded.rangeToReplace; + } + } + + // A non-empty error object in the end-stream frame means the stream failed + // even though the HTTP status was 200. + const streamError = endStream !== undefined && /"error"\s*:/.test(endStream); + return { + ok: !streamError, + status: response.status, + text, + ...(rangeToReplace ? { rangeToReplace } : {}), + ...(streamError ? { errorBody: endStream } : {}), + }; +}; diff --git a/packages/provider-cursor/src/zeta-format.ts b/packages/provider-cursor/src/zeta-format.ts new file mode 100644 index 000000000..66eef34b0 --- /dev/null +++ b/packages/provider-cursor/src/zeta-format.ts @@ -0,0 +1,201 @@ +/** + * Zed edit-prediction marker formats over /v1/completions. + * + * Zed's open_ai_compatible edit-prediction provider sends a rendered prompt (no + * explicit format field) and expects marker-delimited text back. We detect the + * format from the prompt tokens and, for Zeta 2.1 (V0318 SeedMultiRegions), + * parse the editable region + cursor + edit history into a StreamCpp request, + * then render Cursor's rewritten file back as a `<|marker_1|>…<|marker_K|>` span. + * + * V0318 layout (SPM order), byte-exact per Zed's `zeta_prompt` crate: + * <[fim-suffix]>{file suffix}\n<[fim-prefix]>{diagnostics}{PATH\n + * {related-file excerpts}}{edit_history\n{diffs}}{target}\n + * {code before region}<|marker_1|>{block_1}<|marker_2|>{block_2}…<|marker_K|>\n + * <[fim-middle]> + * The numbered markers are separators partitioning the editable region into K + * blocks at line boundaries — a marker is immediately followed by its block + * (no newline). `<|user_cursor|>` sits inside exactly one block. + * + * Output (what the model — and therefore we — emit): the rewritten region as + * `<|marker_1|>{new region, with <|user_cursor|>}<|marker_K|>` + end token. Zed + * maps the first/last output marker to `marker_offsets[value-1]` and replaces + * that span of the old region; using marker_1..marker_K replaces the whole + * region with the reconstructed new text. + */ + +import { applyRewrite } from './completions.ts'; +import type { StreamCppLineRange, StreamCppRequestInput } from './proto/stream-cpp.ts'; + +const FIM_SUFFIX = '<[fim-suffix]>'; +const FIM_PREFIX = '<[fim-prefix]>'; +const FIM_MIDDLE = '<[fim-middle]>'; +const FILENAME = ''; +const CURSOR = '<|user_cursor|>'; +// U+2581 (LOWER ONE EIGHTH BLOCK) inside the seed end-of-sentence token. +export const ZETA_END_MARKER = '<[end▁of▁sentence]>'; + +const MARKER_RE = /<\|marker_([0-9A-Za-z_-]+)\|>/g; +const numericMarker = (id: string): boolean => /^\d+$/.test(id); + +export type PromptFormat = 'zeta-v0318' | 'zeta-v0615' | 'fim' | 'plain'; + +// Zed sends no format field; infer from the prompt body. The Zeta formats carry +// the fim-middle section token plus region markers — numeric ids for V0318, +// hashed ids for V0615. FIM carries a fill-in-the-middle token triple; +// everything else is a plain prefix. +export const detectPromptFormat = (prompt: string): PromptFormat => { + if (prompt.includes(FIM_MIDDLE) && prompt.includes('<|marker_')) { + const ids = [...prompt.matchAll(MARKER_RE)].map(m => m[1]); + if (ids.length > 0 && ids.every(numericMarker)) return 'zeta-v0318'; + return 'zeta-v0615'; + } + if (/<\|fim_prefix\|>||<|fim▁begin|>|<\|code_prefix\|>|
 |\[PREFIX\]/.test(prompt)) return 'fim';
+  return 'plain';
+};
+
+export interface ParsedZeta {
+  targetPath: string;
+  /** Full reconstructed file contents (cursor marker removed). */
+  contents: string;
+  /** File text before the editable region (byte-exact). */
+  codeBefore: string;
+  /** Editable region text (all markers + cursor removed). */
+  editable: string;
+  /** File text after the editable region (byte-exact, as the suffix section). */
+  codeAfter: string;
+  cursorLine: number;
+  cursorColumn: number;
+  /** Number of region markers K — the output's closing marker is marker_K. */
+  markerCount: number;
+  diffHistory: string[];
+}
+
+// Walk a region, dropping every `<|marker_N|>` separator and the `<|user_cursor|>`
+// tag, recording where the cursor landed in the cleaned text.
+const stripRegionMarkers = (region: string): { editable: string; cursorOffset: number | null } => {
+  let out = '';
+  let cursor: number | null = null;
+  let i = 0;
+  while (i < region.length) {
+    if (region.startsWith(CURSOR, i)) { cursor = out.length; i += CURSOR.length; continue; }
+    if (region[i] === '<') {
+      const m = /^<\|marker_[0-9A-Za-z_-]+\|>/.exec(region.slice(i));
+      if (m) { i += m[0].length; continue; }
+    }
+    out += region[i];
+    i += 1;
+  }
+  return { editable: out, cursorOffset: cursor };
+};
+
+// Parse a Zeta V0318 prompt into the pieces needed to build a StreamCpp request.
+// Returns null when the prompt isn't a well-formed single-target Zeta prompt
+// (the caller then falls back to the FIM/plain path).
+export const parseZetaV0318 = (prompt: string): ParsedZeta | null => {
+  const midIdx = prompt.indexOf(FIM_MIDDLE);
+  if (midIdx < 0) return null;
+  const body = prompt.slice(0, midIdx);
+
+  const sufIdx = body.indexOf(FIM_SUFFIX);
+  const preIdx = body.indexOf(FIM_PREFIX);
+  if (sufIdx < 0 || preIdx < 0 || preIdx < sufIdx) return null;
+
+  // Suffix section body is `{file suffix}` verbatim (Zed appends a trailing
+  // newline when the file suffix lacks one); we reuse it verbatim so the
+  // reconstruct/extract round-trip stays self-consistent.
+  const codeAfter = body.slice(sufIdx + FIM_SUFFIX.length, preIdx);
+  const prefixSection = body.slice(preIdx + FIM_PREFIX.length);
+
+  // File sections: `PATH\n{content}` repeated. edit_history is one of
+  // them; the target file is the section carrying the region markers.
+  let target: { path: string; content: string } | null = null;
+  const diffHistory: string[] = [];
+  for (const part of prefixSection.split(FILENAME)) {
+    if (part.length === 0) continue;
+    const nl = part.indexOf('\n');
+    const path = nl >= 0 ? part.slice(0, nl) : part;
+    const content = nl >= 0 ? part.slice(nl + 1) : '';
+    if (path === 'edit_history') { const d = content.replace(/\n$/, ''); if (d.trim().length > 0) diffHistory.push(d); continue; }
+    if (content.includes('<|marker_')) target = { path, content };
+  }
+  if (!target) return null;
+
+  // Locate the first and last region markers. codeBefore is everything up to
+  // marker_1; the editable region is between marker_1 and marker_K (the final
+  // marker sits at the region end with no block after it).
+  const markers = [...target.content.matchAll(MARKER_RE)];
+  if (markers.length < 2) return null;
+  const first = markers[0];
+  const last = markers[markers.length - 1];
+  const codeBefore = target.content.slice(0, first.index);
+  const regionBetween = target.content.slice(first.index + first[0].length, last.index);
+  const { editable, cursorOffset } = stripRegionMarkers(regionBetween);
+
+  const contents = codeBefore + editable + codeAfter;
+  const beforeCursor = codeBefore + editable.slice(0, cursorOffset ?? editable.length);
+  const lines = beforeCursor.split('\n');
+
+  return {
+    targetPath: target.path,
+    contents,
+    codeBefore,
+    editable,
+    codeAfter,
+    cursorLine: lines.length - 1,
+    cursorColumn: lines[lines.length - 1].length,
+    markerCount: markers.length,
+    diffHistory,
+  };
+};
+
+export const streamCppInputForZeta = (parsed: ParsedZeta, modelName: string): StreamCppRequestInput => ({
+  relativePath: parsed.targetPath,
+  contents: parsed.contents,
+  cursorLine: parsed.cursorLine,
+  cursorColumn: parsed.cursorColumn,
+  languageId: '',
+  modelName,
+  ...(parsed.diffHistory.length > 0 ? { diffHistory: parsed.diffHistory } : {}),
+});
+
+// Applying StreamCpp's rewritten-region text to the whole file is the same
+// operation used by the FIM path — see applyRewrite in completions.ts.
+
+export const commonPrefixLen = (a: string, b: string): number => { let i = 0; while (i < a.length && i < b.length && a[i] === b[i]) i++; return i; };
+export const commonSuffixLen = (a: string, b: string, cap: number): number => { let i = 0; while (i < cap && a[a.length - 1 - i] === b[b.length - 1 - i]) i++; return i; };
+
+// Offset in `newText` at the end of the change vs `oldText` (after the common
+// prefix / before the common suffix). A lone trailing-newline difference is
+// ignored first — Cursor's rewrite often drops the region's final newline, and
+// letting that zero the common suffix would push the cursor to the very end of
+// the region (past the real edit). Exported for the V0615 renderer.
+export const cursorAtChangeEnd = (oldText: string, newText: string): number => {
+  const o = oldText.endsWith('\n') ? oldText.slice(0, -1) : oldText;
+  const n = newText.endsWith('\n') ? newText.slice(0, -1) : newText;
+  const cp = commonPrefixLen(o, n);
+  const cs = commonSuffixLen(o, n, Math.min(o.length, n.length) - cp);
+  return n.length - cs;
+};
+
+// Render Cursor's edit as a Zeta V0318 output span. Applies Cursor's
+// (text, range_to_replace) to the file, extracts the new editable region (must
+// preserve codeBefore/codeAfter — otherwise Cursor edited outside the region),
+// places <|user_cursor|> at the end of the change, and emits
+// `<|marker_1|>{new region}<|marker_K|>` + end token. Returns null when there
+// is no in-region change (Zed then shows no suggestion).
+export const renderZetaV0318Output = (parsed: ParsedZeta, range: StreamCppLineRange | undefined, text: string): string | null => {
+  if (!text) return null;
+  const newFile = applyRewrite(parsed.contents, range, text);
+  if (!newFile.startsWith(parsed.codeBefore)) return null;
+  let newEditable = newFile.slice(parsed.codeBefore.length);
+  if (parsed.codeAfter.length > 0) {
+    if (!newEditable.endsWith(parsed.codeAfter)) return null;
+    newEditable = newEditable.slice(0, newEditable.length - parsed.codeAfter.length);
+  }
+  if (newEditable === parsed.editable) return null;
+
+  const cursorAt = cursorAtChangeEnd(parsed.editable, newEditable);
+  const withCursor = newEditable.slice(0, cursorAt) + CURSOR + newEditable.slice(cursorAt);
+
+  return `<|marker_1|>${withCursor}<|marker_${parsed.markerCount}|>${ZETA_END_MARKER}`;
+};
diff --git a/packages/provider-cursor/src/zeta-format_test.ts b/packages/provider-cursor/src/zeta-format_test.ts
new file mode 100644
index 000000000..24bceed8a
--- /dev/null
+++ b/packages/provider-cursor/src/zeta-format_test.ts
@@ -0,0 +1,128 @@
+import { describe, expect, test } from 'vitest';
+
+import { detectPromptFormat, parseZetaV0318, renderZetaV0318Output, streamCppInputForZeta, ZETA_END_MARKER } from './zeta-format.ts';
+
+// A byte-exact V0318 prompt in the shape Zed's zeta_prompt crate emits: SPM
+// order, markers as separators (marker immediately followed by its block),
+// `<|user_cursor|>` inside one block, target section carries the markers.
+const codeBefore = 'fn main() {\n';
+const block1 = '    let a = 1;\n    let b = 2;\n';
+const block2 = '    let c = a<|user_cursor|>\n}\n';
+const editable = '    let a = 1;\n    let b = 2;\n    let c = a\n}\n';
+const zetaPrompt =
+  '<[fim-suffix]>\n<[fim-prefix]>lib/helper.rs\nfn helper() {}\n' +
+  'edit_history\n--- a/src/main.rs\n+++ b/src/main.rs\n-let a=0;\n+let a = 1;\n' +
+  `src/main.rs\n${codeBefore}<|marker_1|>${block1}<|marker_2|>${block2}<|marker_3|>\n<[fim-middle]>`;
+
+describe('detectPromptFormat', () => {
+  test('V0318 numeric markers', () => {
+    expect(detectPromptFormat(zetaPrompt)).toBe('zeta-v0318');
+  });
+  test('V0615 hashed markers', () => {
+    expect(detectPromptFormat('<[fim-prefix]><|marker_aB3_|>x<|marker_9Zq-|><[fim-middle]>')).toBe('zeta-v0615');
+  });
+  test('FIM triple', () => {
+    expect(detectPromptFormat('ab')).toBe('fim');
+  });
+  test('plain prompt', () => {
+    expect(detectPromptFormat('def f():\n    return ')).toBe('plain');
+  });
+});
+
+describe('parseZetaV0318', () => {
+  const p = parseZetaV0318(zetaPrompt)!;
+  test('extracts the target section path', () => {
+    expect(p.targetPath).toBe('src/main.rs');
+  });
+  test('reconstructs codeBefore / editable / codeAfter and marker count', () => {
+    expect(p.codeBefore).toBe(codeBefore);
+    expect(p.editable).toBe(editable); // markers + cursor stripped
+    expect(p.codeAfter).toBe('\n'); // suffix section body (Zed's appended newline)
+    expect(p.markerCount).toBe(3);
+    expect(p.contents).toBe(`${codeBefore}${editable}\n`);
+  });
+  test('cursor lands right after "let c = a"', () => {
+    const lineText = p.contents.split('\n')[p.cursorLine];
+    expect(lineText).toBe('    let c = a');
+    expect(p.cursorColumn).toBe('    let c = a'.length);
+  });
+  test('captures edit history', () => {
+    expect(p.diffHistory).toHaveLength(1);
+    expect(p.diffHistory[0]).toContain('+let a = 1;');
+  });
+  test('non-Zeta prompt returns null', () => {
+    expect(parseZetaV0318('ab')).toBeNull();
+  });
+});
+
+describe('streamCppInputForZeta', () => {
+  test('builds a StreamCpp request from the parsed region', () => {
+    const p = parseZetaV0318(zetaPrompt)!;
+    const input = streamCppInputForZeta(p, 'fast');
+    expect(input.relativePath).toBe('src/main.rs');
+    expect(input.contents).toBe(p.contents);
+    expect(input.modelName).toBe('fast');
+    expect(input.diffHistory).toHaveLength(1);
+  });
+});
+
+describe('renderZetaV0318Output', () => {
+  const p = parseZetaV0318(zetaPrompt)!;
+  // Editable region occupies file lines 2..5. Cursor's end line co-varies with
+  // text's trailing newline; the splice keys off newline counts, not this value.
+  const startLine = codeBefore.split('\n').length; // 2
+  const editableLines = editable.endsWith('\n') ? editable.split('\n').length - 1 : editable.split('\n').length;
+  const endLine = startLine + editableLines; // Cursor's end line for a full-region rewrite
+
+  test('emits marker_1…marker_K span with the rewritten region + cursor + end token', () => {
+    const newEditable = editable.replace('let c = a', 'let c = a + b;');
+    const out = renderZetaV0318Output(p, { startLineNumber: startLine, endLine }, newEditable)!;
+    expect(out.startsWith('<|marker_1|>')).toBe(true);
+    expect(out.endsWith(`<|marker_3|>${ZETA_END_MARKER}`)).toBe(true);
+    expect(out).toContain('<|user_cursor|>');
+    // Zed replaces the whole region with the content between marker_1 and
+    // marker_K (cursor marker stripped) — must equal our new region.
+    const inner = out.slice('<|marker_1|>'.length, out.lastIndexOf('<|marker_3|>'));
+    expect(inner.replace('<|user_cursor|>', '')).toBe(newEditable);
+  });
+
+  test('cursor marker sits at the end of the change span', () => {
+    const newEditable = editable.replace('let c = a', 'let c = a + b;');
+    const out = renderZetaV0318Output(p, { startLineNumber: startLine, endLine }, newEditable)!;
+    expect(out).toContain('let c = a + b;<|user_cursor|>');
+  });
+
+  test('no in-region change → null (no suggestion)', () => {
+    const out = renderZetaV0318Output(p, { startLineNumber: startLine, endLine }, editable);
+    expect(out).toBeNull();
+  });
+
+  test('edit outside the region (breaks codeBefore) → null', () => {
+    // Rewrite the whole file changing codeBefore — codeAfter/codeBefore no longer match.
+    const out = renderZetaV0318Output(p, { startLineNumber: 1, endLine: 2 }, 'fn changed() {');
+    expect(out).toBeNull();
+  });
+
+  test('empty text → null', () => {
+    expect(renderZetaV0318Output(p, undefined, '')).toBeNull();
+  });
+
+  test('multi-line insertion: preserves the trailing line and places the cursor after the inserted line', () => {
+    // Captured INS1 shape: object literal gains a new entry; the region rewrite
+    // spans the object, text ends in a newline. Cursor must not jump past `};`.
+    const cb = 'const routes = {\n';
+    const ca = '\nexport default routes;\n';
+    const prompt =
+      `<[fim-suffix]>\n${ca}<[fim-prefix]>r.js\n` +
+      'edit_history\n--- a/r.js\n+++ b/r.js\n@@\n home\n+ x\n' +
+      `r.js\n${cb}<|marker_1|>  home: '/',\n  about: '/about',<|user_cursor|>\n};\n<|marker_2|>\n<[fim-middle]>`;
+    const parsed = parseZetaV0318(prompt)!;
+    const text = "const routes = {\n  home: '/',\n  about: '/about',\n  contact: '/contact',\n};\n\n";
+    const out = renderZetaV0318Output(parsed, { startLineNumber: 1, endLine: 6 }, text)!;
+    // cursor sits before `};`, after the inserted `  contact: '/contact',` line
+    expect(out).toContain("  contact: '/contact',\n<|user_cursor|>};");
+    // the region rewrite (cursor stripped) is the new object body (region keeps its trailing newline)
+    const inner = out.slice('<|marker_1|>'.length, out.lastIndexOf('<|marker_2|>')).replace('<|user_cursor|>', '');
+    expect(inner).toBe("  home: '/',\n  about: '/about',\n  contact: '/contact',\n};\n");
+  });
+});
diff --git a/packages/provider-cursor/src/zeta-v0615.ts b/packages/provider-cursor/src/zeta-v0615.ts
new file mode 100644
index 000000000..28f027a67
--- /dev/null
+++ b/packages/provider-cursor/src/zeta-v0615.ts
@@ -0,0 +1,177 @@
+/**
+ * Zeta V0615 hashed-regions edit-prediction format (cross-file capable).
+ *
+ * Not reachable from Zed's GUI `prompt_format` menu, but implemented for
+ * completeness and for custom clients: it is the only Zeta format whose markers
+ * are content-hashed and addressable across every related-file excerpt, so a
+ * prediction can target a file other than the cursor's.
+ *
+ * Prompt layout (per Zed's `hashed_regions`): all context lives in the prefix
+ * block; each excerpt is `PATH\n` then, for K region markers,
+ *   <|marker_h0|>\n{block_0}<|marker_h1|>\n{block_1}…<|marker_h{K-1}|>
+ * where a marker is written on its own line and its block follows on the next
+ * line (the writer inserts a `\n` after every non-final marker; interior blocks
+ * already end in `\n`). `<|user_cursor|>` sits inside one block of the cursor
+ * file. Marker ids are content hashes — we never recompute them: Zed rebuilds
+ * the same marker table deterministically when parsing our output, so we echo
+ * the prompt's ids verbatim and let Zed resolve them to byte offsets.
+ *
+ * Output (what the model emits, one block per edited span):
+ *   <|marker_start|>\n{new span, optional <|user_cursor|>}\n<|marker_end|>
+ * blocks joined by `\n`, then the seed end token. Zed pairs markers two at a
+ * time, looks each id up in its table, and replaces old_text[start..end] with
+ * the span — normalizing boundary newlines — so our span need only be the
+ * correct new text for the bracketed range.
+ */
+
+import { applyRewrite } from './completions.ts';
+import type { StreamCppLineRange, StreamCppRequestInput } from './proto/stream-cpp.ts';
+import { commonPrefixLen, commonSuffixLen, cursorAtChangeEnd, ZETA_END_MARKER } from './zeta-format.ts';
+
+const FIM_PREFIX = '<[fim-prefix]>';
+const FIM_MIDDLE = '<[fim-middle]>';
+const FILENAME = '';
+const CURSOR = '<|user_cursor|>';
+const MARKER_RE = /<\|marker_([0-9A-Za-z_-]+)\|>/g;
+
+const markerTag = (id: string): string => `<|marker_${id}|>`;
+
+export interface V0615Marker { id: string; offset: number }
+export interface V0615Snippet { path: string; text: string; markers: V0615Marker[] }
+export interface ParsedV0615 {
+  snippets: V0615Snippet[];
+  cursorSnippetIx: number;
+  cursorOffset: number;
+  diffHistory: string[];
+}
+
+const stripOneLeadingNewline = (s: string): string => (s.startsWith('\n') ? s.slice(1) : s);
+
+// Recover a snippet's clean excerpt text, its ordered markers (id + offset into
+// the clean text), and the cursor offset, from the rendered `` body.
+const parseSnippetContent = (content: string): { text: string; markers: V0615Marker[]; cursor: number | null } => {
+  const ids: string[] = [];
+  const parts: string[] = [];
+  let prev = 0;
+  MARKER_RE.lastIndex = 0;
+  let m: RegExpExecArray | null;
+  while ((m = MARKER_RE.exec(content)) !== null) {
+    ids.push(m[1]);
+    parts.push(content.slice(prev, m.index));
+    prev = MARKER_RE.lastIndex;
+  }
+  parts.push(content.slice(prev));
+
+  const markers: V0615Marker[] = [];
+  let text = '';
+  let cursor: number | null = null;
+  for (let i = 0; i < ids.length; i++) {
+    markers.push({ id: ids[i], offset: text.length });
+    if (i < ids.length - 1) {
+      // Block that follows marker i is parts[i + 1] with the writer's inserted
+      // newline stripped. Interior blocks retain their own trailing newline.
+      const block = stripOneLeadingNewline(parts[i + 1]);
+      const ci = block.indexOf(CURSOR);
+      if (ci >= 0) {
+        cursor = text.length + ci;
+        text += block.slice(0, ci) + block.slice(ci + CURSOR.length);
+      } else {
+        text += block;
+      }
+    }
+  }
+  return { text, markers, cursor };
+};
+
+// Parse a V0615 prompt into per-file snippets, the cursor location, and edit
+// history. Returns null when there is no cursor-bearing excerpt.
+export const parseZetaV0615 = (prompt: string): ParsedV0615 | null => {
+  const midIdx = prompt.indexOf(FIM_MIDDLE);
+  const preIdx = prompt.indexOf(FIM_PREFIX);
+  if (midIdx < 0 || preIdx < 0 || preIdx > midIdx) return null;
+  const prefixSection = prompt.slice(preIdx + FIM_PREFIX.length, midIdx);
+
+  const snippets: V0615Snippet[] = [];
+  const diffHistory: string[] = [];
+  let cursorSnippetIx = -1;
+  let cursorOffset = 0;
+  for (const part of prefixSection.split(FILENAME)) {
+    if (part.length === 0) continue;
+    const nl = part.indexOf('\n');
+    const path = nl >= 0 ? part.slice(0, nl) : part;
+    const content = nl >= 0 ? part.slice(nl + 1) : '';
+    if (path === 'edit_history') { const d = content.replace(/\n$/, ''); if (d.trim().length > 0) diffHistory.push(d); continue; }
+    if (!content.includes('<|marker_')) continue;
+    const parsed = parseSnippetContent(content);
+    if (parsed.cursor !== null) { cursorSnippetIx = snippets.length; cursorOffset = parsed.cursor; }
+    snippets.push({ path, text: parsed.text, markers: parsed.markers });
+  }
+  if (cursorSnippetIx < 0) return null;
+  return { snippets, cursorSnippetIx, cursorOffset, diffHistory };
+};
+
+export const streamCppInputForV0615 = (parsed: ParsedV0615, modelName: string): StreamCppRequestInput => {
+  const snip = parsed.snippets[parsed.cursorSnippetIx];
+  const before = snip.text.slice(0, parsed.cursorOffset).split('\n');
+  return {
+    relativePath: snip.path,
+    contents: snip.text,
+    cursorLine: before.length - 1,
+    cursorColumn: before[before.length - 1].length,
+    languageId: '',
+    modelName,
+    ...(parsed.diffHistory.length > 0 ? { diffHistory: parsed.diffHistory } : {}),
+  };
+};
+
+// The StreamCpp splice + common-prefix/suffix scan are shared with the FIM +
+// V0318 paths — see applyRewrite / commonPrefixLen / commonSuffixLen in
+// completions.ts and zeta-format.ts.
+
+// Port of hashed_regions::encode_from_old_and_new: pick the marker pair that
+// brackets the single contiguous change (common prefix/suffix), emit the new
+// span between them with the cursor inserted if it lands inside.
+const encodeFromOldAndNew = (oldText: string, newText: string, markers: V0615Marker[], cursorInNew: number | null): string => {
+  const cp = commonPrefixLen(oldText, newText);
+  const cs = commonSuffixLen(oldText, newText, Math.min(oldText.length, newText.length) - cp);
+  const changeEndInOld = oldText.length - cs;
+
+  let startIx = 0;
+  for (let i = 0; i < markers.length; i++) if (markers[i].offset <= cp) startIx = i;
+  let endIx = markers.length - 1;
+  for (let i = 0; i < markers.length; i++) if (markers[i].offset >= changeEndInOld) { endIx = i; break; }
+  if (startIx === endIx) {
+    if (endIx < markers.length - 1) endIx += 1;
+    else if (startIx > 0) startIx -= 1;
+  }
+
+  const oldEnd = markers[endIx].offset;
+  const newStart = markers[startIx].offset;
+  const newEnd = newText.length - (oldText.length - oldEnd);
+  const newSpan = newText.slice(newStart, newEnd);
+
+  let result = `${markerTag(markers[startIx].id)}\n`;
+  if (cursorInNew !== null && cursorInNew >= newStart && cursorInNew <= newEnd) {
+    const c = cursorInNew - newStart;
+    result += newSpan.slice(0, c) + CURSOR + newSpan.slice(c);
+  } else {
+    result += newSpan;
+  }
+  if (!result.endsWith('\n')) result += '\n';
+  result += markerTag(markers[endIx].id);
+  return result;
+};
+
+// Render Cursor's edit to the cursor-file excerpt as a V0615 hashed-region
+// span. Returns null when the excerpt is unchanged or has too few markers.
+export const renderV0615Output = (parsed: ParsedV0615, range: StreamCppLineRange | undefined, text: string): string | null => {
+  if (!text) return null;
+  const snip = parsed.snippets[parsed.cursorSnippetIx];
+  if (snip.markers.length < 2) return null;
+  const oldText = snip.text;
+  const newText = applyRewrite(oldText, range, text);
+  if (newText === oldText) return null;
+
+  const cursorInNew = cursorAtChangeEnd(oldText, newText); // trailing-newline-insensitive
+  return encodeFromOldAndNew(oldText, newText, snip.markers, cursorInNew) + ZETA_END_MARKER;
+};
diff --git a/packages/provider-cursor/src/zeta-v0615_test.ts b/packages/provider-cursor/src/zeta-v0615_test.ts
new file mode 100644
index 000000000..b8107d9ca
--- /dev/null
+++ b/packages/provider-cursor/src/zeta-v0615_test.ts
@@ -0,0 +1,116 @@
+import { describe, expect, test } from 'vitest';
+
+import { detectPromptFormat, ZETA_END_MARKER } from './zeta-format.ts';
+import { parseZetaV0615, renderV0615Output, streamCppInputForV0615 } from './zeta-v0615.ts';
+
+// Faithful port of Zed's hashed_regions::write_snippet_with_markers: marker on
+// its own line, block on the next; a `\n` is inserted after every non-final
+// marker, and before a marker when the running output doesn't end in one.
+const writeSnippet = (text: string, markers: { id: string; offset: number }[], cursor: { offset: number } | null): string => {
+  let out = '';
+  let cursorPlaced = false;
+  for (let i = 0; i < markers.length; i++) {
+    if (out.length > 0 && !out.endsWith('\n')) out += '\n';
+    out += `<|marker_${markers[i].id}|>`;
+    const next = markers[i + 1];
+    if (next) {
+      out += '\n';
+      const block = text.slice(markers[i].offset, next.offset);
+      if (cursor && !cursorPlaced && cursor.offset >= markers[i].offset && cursor.offset <= next.offset) {
+        cursorPlaced = true;
+        const c = cursor.offset - markers[i].offset;
+        out += `${block.slice(0, c)}<|user_cursor|>${block.slice(c)}`;
+      } else {
+        out += block;
+      }
+    }
+  }
+  return out;
+};
+
+// Cursor file excerpt: three line-boundary blocks → markers at 0, o1, len.
+const excerpt = 'fn a() {\n    x\n}\nfn b() {\n    y\n}\nfn c() {\n    z\n}\n';
+const o1 = 'fn a() {\n    x\n}\n'.length;
+const o2 = 'fn a() {\n    x\n}\nfn b() {\n    y\n}\n'.length;
+const markers = [{ id: 'AAAA', offset: 0 }, { id: 'BBBB', offset: o1 }, { id: 'CCCC', offset: o2 }, { id: 'DDDD', offset: excerpt.length }];
+const cursorOffset = 'fn a() {\n    x\n}\nfn b() {\n    y'.length; // after "y" in block 2
+
+const relatedSnippet = writeSnippet('helper()\n', [{ id: 'ZZZZ', offset: 0 }, { id: 'YYYY', offset: 'helper()\n'.length }], null);
+const cursorSnippet = writeSnippet(excerpt, markers, { offset: cursorOffset });
+const v0615Prompt =
+  `<[fim-suffix]><[fim-prefix]>lib/util.rs\n${relatedSnippet}\n` +
+  `src/main.rs\n${cursorSnippet}\n<[fim-middle]>`;
+
+describe('detectPromptFormat / V0615', () => {
+  test('hashed markers detected as V0615', () => {
+    expect(detectPromptFormat(v0615Prompt)).toBe('zeta-v0615');
+  });
+});
+
+describe('parseZetaV0615', () => {
+  const p = parseZetaV0615(v0615Prompt)!;
+  test('recovers both snippets and finds the cursor excerpt', () => {
+    expect(p.snippets).toHaveLength(2);
+    expect(p.snippets[p.cursorSnippetIx].path).toBe('src/main.rs');
+  });
+  test('reconstructs the cursor excerpt text byte-exact', () => {
+    expect(p.snippets[p.cursorSnippetIx].text).toBe(excerpt);
+  });
+  test('recovers marker ids and clean-text offsets', () => {
+    expect(p.snippets[p.cursorSnippetIx].markers).toEqual(markers);
+  });
+  test('cursor offset lands right after "y"', () => {
+    expect(p.cursorOffset).toBe(cursorOffset);
+    expect(p.snippets[p.cursorSnippetIx].text.slice(0, p.cursorOffset).endsWith('    y')).toBe(true);
+  });
+  test('non-cursor snippet parsed too', () => {
+    const other = p.snippets.find(s => s.path === 'lib/util.rs')!;
+    expect(other.text).toBe('helper()\n');
+  });
+});
+
+describe('streamCppInputForV0615', () => {
+  test('targets the cursor file with the excerpt as contents', () => {
+    const p = parseZetaV0615(v0615Prompt)!;
+    const input = streamCppInputForV0615(p, 'fast');
+    expect(input.relativePath).toBe('src/main.rs');
+    expect(input.contents).toBe(excerpt);
+    expect(input.cursorLine).toBe(4); // 0-indexed line of "    y"
+  });
+});
+
+describe('renderV0615Output', () => {
+  const p = parseZetaV0615(v0615Prompt)!;
+  // Rewrite line "    y" (file line 5) → "    y = 1;".
+  const newExcerpt = excerpt.replace('    y\n', '    y = 1;\n');
+
+  test('emits a marker-bounded span + end token, cursor at the change', () => {
+    const out = renderV0615Output(p, { startLineNumber: 5, endLine: 6 }, '    y = 1;\n')!;
+    expect(out.endsWith(ZETA_END_MARKER)).toBe(true);
+    expect(out).toMatch(/^<\|marker_[A-Z]{4}\|>\n/);
+    expect(out).toContain('<|user_cursor|>');
+  });
+
+  test('a simulated Zed parse (id→offset lookup, replace span) reconstructs the new excerpt', () => {
+    const out = renderV0615Output(p, { startLineNumber: 5, endLine: 6 }, '    y = 1;\n')!;
+    const body = out.slice(0, out.length - ZETA_END_MARKER.length);
+    // pair the two markers, take content between them, strip a leading newline + cursor
+    const tags = [...body.matchAll(/<\|marker_([A-Z]{4})\|>/g)];
+    expect(tags.length).toBe(2);
+    const [startTag, endTag] = tags;
+    let span = body.slice(startTag.index! + startTag[0].length, endTag.index!);
+    span = span.replace(/^\n/, '').replace('<|user_cursor|>', '');
+    const byId = Object.fromEntries(markers.map(m => [m.id, m.offset]));
+    const startByte = byId[startTag[1]];
+    const endByte = byId[endTag[1]];
+    // Zed normalizes: old span ends with \n, keep span's trailing \n.
+    const oldSpan = excerpt.slice(startByte, endByte);
+    if (oldSpan.endsWith('\n') && !span.endsWith('\n') && span.length > 0) span += '\n';
+    const rebuilt = excerpt.slice(0, startByte) + span + excerpt.slice(endByte);
+    expect(rebuilt).toBe(newExcerpt);
+  });
+
+  test('unchanged excerpt → null', () => {
+    expect(renderV0615Output(p, { startLineNumber: 5, endLine: 6 }, '    y\n')).toBeNull();
+  });
+});
diff --git a/packages/provider-cursor/tsconfig.json b/packages/provider-cursor/tsconfig.json
new file mode 100644
index 000000000..1cea5297e
--- /dev/null
+++ b/packages/provider-cursor/tsconfig.json
@@ -0,0 +1,4 @@
+{
+  "extends": "../../tsconfig.base.json",
+  "include": ["src/**/*.ts"]
+}
\ No newline at end of file
diff --git a/packages/provider-cursor/vitest.config.ts b/packages/provider-cursor/vitest.config.ts
new file mode 100644
index 000000000..a6268ec7f
--- /dev/null
+++ b/packages/provider-cursor/vitest.config.ts
@@ -0,0 +1,8 @@
+import { defineConfig } from 'vitest/config';
+
+export default defineConfig({
+  test: {
+    include: ['src/**/*_test.ts'],
+    environment: 'node',
+  },
+});
\ No newline at end of file
diff --git a/packages/provider/src/flags.ts b/packages/provider/src/flags.ts
index 8512226e1..ae8cc83dd 100644
--- a/packages/provider/src/flags.ts
+++ b/packages/provider/src/flags.ts
@@ -50,7 +50,7 @@ export const OPTIONAL_FLAGS = [
   },
   // The three shim flags default on for every upstream kind that runs
   // operator-shaped traffic through translation. They are off by default on
-  // `codex` (no hosted tools at all) and on `claude-code` (the shaped-
+  // `codex` and `cursor` (no hosted tools at all) and on `claude-code` (the shaped-
   // passthrough path forwards caller bytes verbatim, so a gateway-side shim
   // would silently rewrite a request the operator deliberately let through
   // unchanged).
@@ -58,19 +58,19 @@ export const OPTIONAL_FLAGS = [
     id: 'messages-web-search-shim',
     label: 'Messages web search shim',
     description: "Execute Anthropic native Messages web search through the gateway's configured search provider instead of forwarding it to the upstream. (When a client Messages request is routed to a non-Messages backend, the shim always runs regardless of this flag, because those targets cannot carry Anthropic server tools.)",
-    defaultFor: ALL_PROVIDER_KINDS.filter(p => !['codex', 'claude-code'].includes(p)),
+    defaultFor: ALL_PROVIDER_KINDS.filter(p => !['codex', 'claude-code', 'cursor'].includes(p)),
   },
   {
     id: 'responses-web-search-shim',
     label: 'Responses web search shim',
     description: "Execute the Responses `web_search` hosted tool through the gateway's configured search provider instead of forwarding it to a Responses upstream. (When a Responses request is routed to a non-Responses backend, the shim always runs regardless of this flag, because those targets cannot carry hosted web_search.)",
-    defaultFor: ALL_PROVIDER_KINDS.filter(p => !['codex', 'claude-code'].includes(p)),
+    defaultFor: ALL_PROVIDER_KINDS.filter(p => !['codex', 'claude-code', 'cursor'].includes(p)),
   },
   {
     id: 'responses-image-generation-shim',
     label: 'Responses image generation shim',
     description: "Execute the Responses `image_generation` hosted tool through the gateway's image-capable upstream (gpt-image-*) instead of forwarding it to a Responses upstream. The orchestrator model calls a generated function tool; the shim drives the standalone /images/{generations,edits} backend and synthesizes the native image_generation_call lifecycle. (When a Responses request is routed to a non-Responses backend, the shim always runs regardless of this flag, because those targets cannot carry the hosted image_generation tool.)",
-    defaultFor: ALL_PROVIDER_KINDS.filter(p => !['codex', 'claude-code'].includes(p)),
+    defaultFor: ALL_PROVIDER_KINDS.filter(p => !['codex', 'claude-code', 'cursor'].includes(p)),
   },
   {
     id: 'responses-compact-shim',
diff --git a/packages/provider/src/index.ts b/packages/provider/src/index.ts
index c5daa711d..a86d0dd48 100644
--- a/packages/provider/src/index.ts
+++ b/packages/provider/src/index.ts
@@ -55,7 +55,7 @@ export type {
 export type { ProviderStreamParser } from './streaming.ts';
 export { streamingProviderCall } from './streaming.ts';
 
-export type { ProviderRepo, UpstreamsRepoSlim } from './repo.ts';
+export type { CursorSessionRow, CursorSessionsRepoSlim, ProviderRepo, UpstreamsRepoSlim } from './repo.ts';
 export { getProviderRepo, initProviderRepo } from './repo.ts';
 
 export {
diff --git a/packages/provider/src/model.ts b/packages/provider/src/model.ts
index fc5029d9a..498ed11fa 100644
--- a/packages/provider/src/model.ts
+++ b/packages/provider/src/model.ts
@@ -2,7 +2,7 @@ import type { UpstreamChatModelConfig } from './model-config.ts';
 import type { ModelPrefixConfig } from './model-prefix.ts';
 import type { AliasSelection, AliasTarget, ModelKind, ModelEndpoints, ModelPricing } from '@floway-dev/protocols/common';
 
-export const ALL_PROVIDER_KINDS = ['copilot', 'custom', 'azure', 'codex', 'claude-code', 'ollama'] as const;
+export const ALL_PROVIDER_KINDS = ['copilot', 'custom', 'azure', 'codex', 'claude-code', 'ollama', 'cursor'] as const;
 export type UpstreamProviderKind = typeof ALL_PROVIDER_KINDS[number];
 
 // One entry in `UpstreamRecord.proxyFallbackList`. `id` is the proxy id from
diff --git a/packages/provider/src/provider.ts b/packages/provider/src/provider.ts
index 814af501b..62ae83713 100644
--- a/packages/provider/src/provider.ts
+++ b/packages/provider/src/provider.ts
@@ -105,6 +105,19 @@ export interface UpstreamCallOptions {
   recordUpstreamLatency: (promise: Promise) => Promise;
   waitUntil: (promise: Promise) => void;
   headers: Headers;
+  /**
+   * The API key id that authenticated the inbound request. Threaded from the
+   * gateway's auth middleware (already exposed as `GatewayCtx.apiKeyId`).
+   *
+   * Providers that hold cross-request state combine this with the upstream id
+   * to namespace per-(upstream, apiKey) so a session opened by one API key is
+   * never reachable from another, even when both keys are bound to the same
+   * upstream account. Mirrors the dump-broker convention of keying observation
+   * channels on `apiKey.id` (`gateway/src/dump/accumulator.ts:275`); compare
+   * also the copilot token cache that keys on upstream id alone because OAuth
+   * tokens are upstream-scoped by nature (`provider-copilot/src/auth.ts:42`).
+   */
+  apiKeyId: string;
 }
 
 export interface ProviderInstance {
diff --git a/packages/provider/src/repo.ts b/packages/provider/src/repo.ts
index c6800bfd0..e7f35bae5 100644
--- a/packages/provider/src/repo.ts
+++ b/packages/provider/src/repo.ts
@@ -8,8 +8,47 @@ export interface UpstreamsRepoSlim {
   saveState(id: string, newState: unknown, options: { expectedState: unknown }): Promise<{ updated: boolean }>;
 }
 
+// Cross-instance Cursor session scalars (the durable half of a live agent
+// turn — the read stream lives in the DurableHttpSession). Persisted in D1 so
+// a tool-result follow-up landing on a different isolate can resume the
+// upstream RunSSE without the in-process transport. See migration
+// 0049_cursor_sessions.sql. Structurally matched by the gateway SqlRepo.
+export interface CursorSessionRow {
+  sessionKey: string;
+  requestId: string;
+  /** Next BidiAppend seqno. bigint in the transport; a safe JS number here. */
+  appendSeqno: number;
+  /** RunSSE bytes read past the exec_mcp frame but unconsumed (usually empty). */
+  leftover: Uint8Array | null;
+}
+
+export interface CursorSessionsRepoSlim {
+  /**
+   * Atomically claim (lock for ttlMs) and return the row — single-flight so two
+   * concurrent follow-ups can't both drive the same stream. Returns null if the
+   * session is missing or already claimed (caller cold-resumes).
+   */
+  claim(sessionKey: string, ttlMs: number): Promise;
+  /** Upsert the scalars, clear the claim lock, and refresh the sweep timestamp. */
+  put(row: CursorSessionRow): Promise;
+  delete(sessionKey: string): Promise;
+}
+
 export interface ProviderRepo {
   upstreams: UpstreamsRepoSlim;
+  cursorSessions: CursorSessionsRepoSlim;
+  /**
+   * Resolve an upstream's proxy fallback list into ordered, opaque
+   * `@floway-dev/proxy` ProxyConfig objects (direct entries dropped) for a
+   * provider that must dial a streaming upstream through the proxy itself
+   * (cursor's RunSSE — see DurableHttpSessionInit.proxies). Optional so test
+   * mocks that don't exercise proxying can omit it (callers fall back to direct).
+   */
+  proxies?: ProxyResolverSlim;
+}
+
+export interface ProxyResolverSlim {
+  resolveForUpstream(upstreamId: string): Promise;
 }
 
 let _accessor: (() => ProviderRepo) | null = null;
diff --git a/packages/test-utils/src/index.ts b/packages/test-utils/src/index.ts
index f83ab4ef3..6e5f0062b 100644
--- a/packages/test-utils/src/index.ts
+++ b/packages/test-utils/src/index.ts
@@ -9,4 +9,4 @@ export {
   assertThrows,
 } from './assert.ts';
 export { jsonResponse, sseResponse, withMockedFetch } from './mock-fetch.ts';
-export { noopUpstreamCallOptions, stubInternalModel, stubProvider, stubProviderModel, stubModelCandidate, testTelemetryModelIdentity } from './stubs.ts';
+export { noopCursorSessionsRepo, noopUpstreamCallOptions, stubInternalModel, stubProvider, stubProviderModel, stubModelCandidate, testTelemetryModelIdentity } from './stubs.ts';
diff --git a/packages/test-utils/src/stubs.ts b/packages/test-utils/src/stubs.ts
index 2350a247b..a316bdd1e 100644
--- a/packages/test-utils/src/stubs.ts
+++ b/packages/test-utils/src/stubs.ts
@@ -1,4 +1,12 @@
-import { directFetcher, type InternalModel, type ProviderInstance, type Provider, type ProviderModel, type ModelCandidate, type TelemetryModelIdentity, type UpstreamCallOptions } from '@floway-dev/provider';
+import { directFetcher, type CursorSessionsRepoSlim, type InternalModel, type ProviderInstance, type Provider, type ProviderModel, type ModelCandidate, type TelemetryModelIdentity, type UpstreamCallOptions } from '@floway-dev/provider';
+
+// No-op cursor-sessions slim repo for provider tests that wire initProviderRepo
+// but don't exercise cross-instance session reuse. Always misses on claim.
+export const noopCursorSessionsRepo = (): CursorSessionsRepoSlim => ({
+  claim: async () => null,
+  put: async () => {},
+  delete: async () => {},
+});
 
 // No-op UpstreamCallOptions factory for tests calling provider methods
 // directly: identity recordUpstreamLatency satisfies the contract without
@@ -12,6 +20,7 @@ export const noopUpstreamCallOptions = (overrides: Partial
   recordUpstreamLatency: (promise: Promise): Promise => promise,
   waitUntil: () => {},
   headers: new Headers(),
+  apiKeyId: 'test-api-key',
   ...overrides,
 });
 
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index b5e858e39..7b763f5ae 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -85,6 +85,9 @@ importers:
       '@floway-dev/protocols':
         specifier: workspace:*
         version: link:../../packages/protocols
+      '@floway-dev/proxy':
+        specifier: workspace:*
+        version: link:../../packages/proxy
       hono:
         specifier: ^4
         version: 4.12.22
@@ -107,6 +110,9 @@ importers:
       '@floway-dev/protocols':
         specifier: workspace:*
         version: link:../../packages/protocols
+      '@floway-dev/proxy':
+        specifier: workspace:*
+        version: link:../../packages/proxy
       '@hono/node-server':
         specifier: ^2.0.4
         version: 2.0.4(hono@4.12.22)
@@ -274,6 +280,9 @@ importers:
       '@floway-dev/provider-copilot':
         specifier: workspace:*
         version: link:../provider-copilot
+      '@floway-dev/provider-cursor':
+        specifier: workspace:*
+        version: link:../provider-cursor
       '@floway-dev/provider-custom':
         specifier: workspace:*
         version: link:../provider-custom
@@ -436,6 +445,28 @@ importers:
         specifier: workspace:*
         version: link:../test-utils
 
+  packages/provider-cursor:
+    dependencies:
+      '@floway-dev/http':
+        specifier: workspace:*
+        version: link:../http
+      '@floway-dev/interceptor':
+        specifier: workspace:*
+        version: link:../interceptor
+      '@floway-dev/platform':
+        specifier: workspace:*
+        version: link:../platform
+      '@floway-dev/protocols':
+        specifier: workspace:*
+        version: link:../protocols
+      '@floway-dev/provider':
+        specifier: workspace:*
+        version: link:../provider
+    devDependencies:
+      '@floway-dev/test-utils':
+        specifier: workspace:*
+        version: link:../test-utils
+
   packages/provider-custom:
     dependencies:
       '@floway-dev/interceptor':
diff --git a/vitest.config.ts b/vitest.config.ts
index 622d20528..1da34c459 100644
--- a/vitest.config.ts
+++ b/vitest.config.ts
@@ -18,6 +18,7 @@ export default defineConfig({
       'packages/provider-claude-code/vitest.config.ts',
       'packages/provider-codex/vitest.config.ts',
       'packages/provider-copilot/vitest.config.ts',
+      'packages/provider-cursor/vitest.config.ts',
       'packages/provider-custom/vitest.config.ts',
     ],
   },
diff --git a/wrangler.example.jsonc b/wrangler.example.jsonc
index edd7f6086..5759f21f0 100644
--- a/wrangler.example.jsonc
+++ b/wrangler.example.jsonc
@@ -104,11 +104,22 @@
   ],
   // Per-key WebSocket fan-out actor. Content-agnostic — the actor never
   // inspects the payload; callers layer their own framing via a codec.
+  //
+  // DURABLE_HTTP_SESSION is the first actor that owns a live OUTBOUND socket:
+  // it holds a cross-request upstream HTTP response stream (cursor's RunSSE)
+  // open so a later inbound request can drive an ExecMcpResult continuation on
+  // the same conversation. The outbound socket keeps the DO alive (CF
+  // 2026-06-19 outbound-connections-keep-DOs-alive, 15-minute cap); an idle
+  // alarm (default 5 min) evicts an abandoned session before then.
   "durable_objects": {
     "bindings": [
       {
         "name": "BROADCAST_DO",
         "class_name": "BroadcastDO"
+      },
+      {
+        "name": "DURABLE_HTTP_SESSION",
+        "class_name": "DurableHttpSessionDO"
       }
     ]
   },
@@ -120,12 +131,18 @@
   // CF is phasing out and which Workers Free cannot create.
   //
   // BroadcastDO has no `ctx.storage.*` calls — the SQLite db is provisioned
-  // but unused. The name reflects the BACKEND choice, not whether the actor
-  // touches it.
+  // but unused. DurableHttpSessionDO uses `ctx.storage.setAlarm` for idle
+  // eviction. The name reflects the BACKEND choice, not whether the actor
+  // touches it. Migrations are append-only: v1 created BroadcastDO, v2 adds
+  // DurableHttpSessionDO.
   "migrations": [
     {
       "tag": "v1",
       "new_sqlite_classes": ["BroadcastDO"]
+    },
+    {
+      "tag": "v2",
+      "new_sqlite_classes": ["DurableHttpSessionDO"]
     }
   ]
 }