@gajae-code/ai 0.16.7 → 0.17.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +44 -1
- package/dist/types/auth-storage.d.ts +23 -4
- package/dist/types/models.d.ts +14 -0
- package/dist/types/provider-models/special.d.ts +12 -0
- package/dist/types/providers/anthropic.d.ts +1 -1
- package/dist/types/providers/cursor.d.ts +33 -21
- package/dist/types/providers/devin-acp.d.ts +157 -0
- package/dist/types/providers/google-gemini-headers.d.ts +1 -1
- package/dist/types/providers/mock.d.ts +2 -0
- package/dist/types/providers/openai-responses-shared.d.ts +21 -1
- package/dist/types/providers/register-builtins.d.ts +1 -0
- package/dist/types/types.d.ts +52 -11
- package/dist/types/utils/block-symbols.d.ts +15 -5
- package/dist/types/utils/fallback-transport.d.ts +4 -1
- package/dist/types/utils.d.ts +13 -0
- package/package.json +4 -3
- package/src/api-registry.ts +1 -0
- package/src/auth-broker/redact.ts +10 -2
- package/src/auth-gateway/server.ts +56 -3
- package/src/auth-storage.ts +330 -116
- package/src/model-manager.ts +21 -2
- package/src/models.d.ts +14 -0
- package/src/models.json +117 -0
- package/src/models.ts +18 -0
- package/src/provider-models/descriptors.ts +7 -0
- package/src/provider-models/openai-compat.ts +14 -0
- package/src/provider-models/special.ts +39 -0
- package/src/providers/anthropic.d.ts +1 -1
- package/src/providers/anthropic.ts +1 -1
- package/src/providers/azure-openai-responses.ts +10 -1
- package/src/providers/cursor.d.ts +33 -21
- package/src/providers/cursor.ts +2024 -508
- package/src/providers/devin-acp.d.ts +157 -0
- package/src/providers/devin-acp.ts +1103 -0
- package/src/providers/google-gemini-headers.d.ts +1 -1
- package/src/providers/google-gemini-headers.ts +1 -1
- package/src/providers/mock.ts +16 -1
- package/src/providers/openai-chat-server.ts +3 -3
- package/src/providers/openai-codex-responses.ts +27 -17
- package/src/providers/openai-responses-server.ts +5 -5
- package/src/providers/openai-responses-shared.d.ts +21 -1
- package/src/providers/openai-responses-shared.ts +60 -6
- package/src/providers/openai-responses.ts +10 -1
- package/src/providers/register-builtins.d.ts +1 -0
- package/src/providers/register-builtins.ts +21 -1
- package/src/stream.ts +14 -0
- package/src/types.d.ts +52 -11
- package/src/types.ts +70 -8
- package/src/utils/block-symbols.d.ts +15 -5
- package/src/utils/block-symbols.ts +16 -6
- package/src/utils/discovery/cursor.ts +3 -2
- package/src/utils/fallback-transport.d.ts +4 -1
- package/src/utils/fallback-transport.ts +12 -5
- package/src/utils.d.ts +13 -0
- package/src/utils.ts +17 -0
- package/dist/types/utils/codex-entitlement.d.ts +0 -22
- package/src/utils/codex-entitlement.d.ts +0 -22
- package/src/utils/codex-entitlement.ts +0 -57
package/src/providers/cursor.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { createHash } from "node:crypto";
|
|
2
2
|
import * as fs from "node:fs/promises";
|
|
3
3
|
import http2 from "node:http2";
|
|
4
|
+
import type * as tls from "node:tls";
|
|
4
5
|
import { create, fromBinary, fromJson, type JsonValue, toBinary, toJson } from "@bufbuild/protobuf";
|
|
5
6
|
import { ValueSchema } from "@bufbuild/protobuf/wkt";
|
|
6
7
|
import { $env, extractHttpStatusFromError, sanitizeText } from "@gajae-code/utils";
|
|
@@ -27,9 +28,10 @@ import type {
|
|
|
27
28
|
Usage,
|
|
28
29
|
} from "../types";
|
|
29
30
|
import { normalizeSystemPrompts } from "../utils";
|
|
30
|
-
import {
|
|
31
|
+
import { kProviderResolvedToolCall } from "../utils/block-symbols";
|
|
31
32
|
import { AssistantMessageEventStream } from "../utils/event-stream";
|
|
32
|
-
import {
|
|
33
|
+
import { transportFailureFacts } from "../utils/fallback-transport";
|
|
34
|
+
import { FirstEventTimeoutError, getStreamFirstEventTimeoutMs, getStreamIdleTimeoutMs } from "../utils/idle-iterator";
|
|
33
35
|
import { captureUnicodeEscapeEvidence, parseStreamingJson } from "../utils/json-parse";
|
|
34
36
|
import { connectProxiedSocket, getProxyForUrl } from "../utils/proxy";
|
|
35
37
|
import { formatErrorMessageWithRetryAfter } from "../utils/retry-after";
|
|
@@ -175,9 +177,28 @@ import {
|
|
|
175
177
|
export const CURSOR_API_URL = "https://api2.cursor.sh";
|
|
176
178
|
export { CURSOR_CLIENT_VERSION };
|
|
177
179
|
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
180
|
+
interface CursorConversationContext {
|
|
181
|
+
endpointKey: string;
|
|
182
|
+
credentialKey: string;
|
|
183
|
+
modelKey: string;
|
|
184
|
+
systemPromptKey: string;
|
|
185
|
+
customSystemPromptKey: string;
|
|
186
|
+
toolsKey: string;
|
|
187
|
+
messageKeys: string[];
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
interface CursorConversationCacheEntry {
|
|
191
|
+
state: ConversationStateStructure;
|
|
192
|
+
blobs: Map<string, Uint8Array>;
|
|
193
|
+
context: CursorConversationContext;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
const conversationCache = new Map<string, CursorConversationCacheEntry>();
|
|
197
|
+
// A capped non-abortable mutation may outlive the provider turn. Keep a
|
|
198
|
+
// conversation-scoped lock until the actual handler settles so a retry or a
|
|
199
|
+
// later turn cannot start another request while the detached mutation is still
|
|
200
|
+
// changing local state.
|
|
201
|
+
const conversationMutationLocks = new Map<string, Promise<void>>();
|
|
181
202
|
|
|
182
203
|
// F15: bound the module-global conversation caches so long-lived / many-session use cannot
|
|
183
204
|
// grow them without limit. LRU by conversation count + TTL on idle conversations.
|
|
@@ -185,14 +206,95 @@ const CURSOR_MAX_CONVERSATIONS = 64;
|
|
|
185
206
|
const CURSOR_CONVERSATION_TTL_MS = 60 * 60 * 1000;
|
|
186
207
|
const conversationLastAccess = new Map<string, number>();
|
|
187
208
|
|
|
209
|
+
const conversationMutationLockReservations = new Set<string>();
|
|
210
|
+
|
|
211
|
+
function reserveCursorMutationLock(conversationId: string): boolean {
|
|
212
|
+
if (conversationMutationLocks.has(conversationId) || conversationMutationLockReservations.has(conversationId))
|
|
213
|
+
return false;
|
|
214
|
+
if (conversationMutationLocks.size + conversationMutationLockReservations.size >= CURSOR_MAX_CONVERSATIONS) {
|
|
215
|
+
return false;
|
|
216
|
+
}
|
|
217
|
+
conversationMutationLockReservations.add(conversationId);
|
|
218
|
+
return true;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
function releaseCursorMutationLockReservation(conversationId: string): void {
|
|
222
|
+
conversationMutationLockReservations.delete(conversationId);
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
function registerCursorMutationLock(conversationId: string, lock: Promise<void>): void {
|
|
226
|
+
conversationMutationLocks.set(conversationId, lock);
|
|
227
|
+
void lock.then(() => {
|
|
228
|
+
if (conversationMutationLocks.get(conversationId) === lock) conversationMutationLocks.delete(conversationId);
|
|
229
|
+
});
|
|
230
|
+
}
|
|
231
|
+
|
|
188
232
|
/** Drop all cached state + blob bytes for a conversation (F15 bound + session-teardown hook). */
|
|
189
233
|
export function disposeCursorConversation(conversationId: string): void {
|
|
190
|
-
|
|
191
|
-
conversationBlobStores.delete(conversationId);
|
|
192
|
-
conversationUsageContextCache.delete(conversationId);
|
|
234
|
+
conversationCache.delete(conversationId);
|
|
193
235
|
conversationLastAccess.delete(conversationId);
|
|
194
236
|
}
|
|
195
237
|
|
|
238
|
+
async function waitForCursorSetup<T>(
|
|
239
|
+
setup: Promise<T>,
|
|
240
|
+
signal: AbortSignal | undefined,
|
|
241
|
+
timeoutMs: number | undefined,
|
|
242
|
+
timeoutError: () => Error,
|
|
243
|
+
): Promise<T> {
|
|
244
|
+
setup.catch(() => {});
|
|
245
|
+
const deadline = Promise.withResolvers<never>();
|
|
246
|
+
deadline.promise.catch(() => {});
|
|
247
|
+
const deadlineTimer =
|
|
248
|
+
timeoutMs !== undefined && timeoutMs > 0
|
|
249
|
+
? setTimeout(() => deadline.reject(timeoutError()), timeoutMs)
|
|
250
|
+
: undefined;
|
|
251
|
+
const aborted = Promise.withResolvers<never>();
|
|
252
|
+
aborted.promise.catch(() => {});
|
|
253
|
+
const onAbort = () => aborted.reject(cursorAbortError(signal!));
|
|
254
|
+
if (signal) {
|
|
255
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
256
|
+
if (signal.aborted) onAbort();
|
|
257
|
+
}
|
|
258
|
+
try {
|
|
259
|
+
const racers: Promise<T | never>[] = [setup];
|
|
260
|
+
if (deadlineTimer) racers.push(deadline.promise);
|
|
261
|
+
if (signal) racers.push(aborted.promise);
|
|
262
|
+
return await Promise.race(racers);
|
|
263
|
+
} finally {
|
|
264
|
+
if (signal) signal.removeEventListener("abort", onAbort);
|
|
265
|
+
if (deadlineTimer) clearTimeout(deadlineTimer);
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
async function waitForCursorMutationLock(
|
|
270
|
+
conversationId: string,
|
|
271
|
+
signal: AbortSignal | undefined,
|
|
272
|
+
timeoutMs: number | undefined,
|
|
273
|
+
timeoutError: () => Error,
|
|
274
|
+
): Promise<void> {
|
|
275
|
+
const lock = conversationMutationLocks.get(conversationId);
|
|
276
|
+
if (!lock) return;
|
|
277
|
+
if (signal?.aborted) throw cursorAbortError(signal);
|
|
278
|
+
const deadline = Promise.withResolvers<never>();
|
|
279
|
+
deadline.promise.catch(() => {});
|
|
280
|
+
const deadlineTimer =
|
|
281
|
+
timeoutMs !== undefined && timeoutMs > 0
|
|
282
|
+
? setTimeout(() => deadline.reject(timeoutError()), timeoutMs)
|
|
283
|
+
: undefined;
|
|
284
|
+
const aborted = Promise.withResolvers<never>();
|
|
285
|
+
aborted.promise.catch(() => {});
|
|
286
|
+
const onAbort = () => aborted.reject(cursorAbortError(signal!));
|
|
287
|
+
if (signal) signal.addEventListener("abort", onAbort, { once: true });
|
|
288
|
+
try {
|
|
289
|
+
const racers: Promise<never>[] = [deadline.promise];
|
|
290
|
+
if (signal) racers.push(aborted.promise);
|
|
291
|
+
await Promise.race([lock, ...racers]);
|
|
292
|
+
} finally {
|
|
293
|
+
if (signal) signal.removeEventListener("abort", onAbort);
|
|
294
|
+
if (deadlineTimer) clearTimeout(deadlineTimer);
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
|
|
196
298
|
/** Refresh recency for a conversation and evict TTL-stale / LRU-overflow entries (F15). */
|
|
197
299
|
function touchCursorConversation(conversationId: string): void {
|
|
198
300
|
const now = Date.now();
|
|
@@ -200,13 +302,13 @@ function touchCursorConversation(conversationId: string): void {
|
|
|
200
302
|
if (id !== conversationId && now - ts > CURSOR_CONVERSATION_TTL_MS) disposeCursorConversation(id);
|
|
201
303
|
}
|
|
202
304
|
conversationLastAccess.set(conversationId, now);
|
|
203
|
-
const
|
|
204
|
-
if (
|
|
205
|
-
|
|
206
|
-
|
|
305
|
+
const entry = conversationCache.get(conversationId);
|
|
306
|
+
if (entry !== undefined) {
|
|
307
|
+
conversationCache.delete(conversationId);
|
|
308
|
+
conversationCache.set(conversationId, entry);
|
|
207
309
|
}
|
|
208
|
-
while (
|
|
209
|
-
const oldest =
|
|
310
|
+
while (conversationCache.size > CURSOR_MAX_CONVERSATIONS) {
|
|
311
|
+
const oldest = conversationCache.keys().next().value;
|
|
210
312
|
if (oldest === undefined || oldest === conversationId) break;
|
|
211
313
|
disposeCursorConversation(oldest);
|
|
212
314
|
}
|
|
@@ -220,6 +322,316 @@ export interface CursorOptions extends StreamOptions {
|
|
|
220
322
|
}
|
|
221
323
|
|
|
222
324
|
const CONNECT_END_STREAM_FLAG = 0b00000010;
|
|
325
|
+
const CURSOR_MAX_PENDING_SERVER_MESSAGES = 256;
|
|
326
|
+
const CURSOR_MAX_QUEUED_SERVER_BYTES = 64 * 1024 * 1024;
|
|
327
|
+
// Connect frames routinely carry tool payloads and checkpoint blobs larger than
|
|
328
|
+
// 4 KiB. Keep a finite protocol bound for hostile peers, but do not reject
|
|
329
|
+
// valid server messages merely because they exceed the old debug-text limit.
|
|
330
|
+
const CURSOR_MAX_GRPC_MESSAGE_LENGTH = 16 * 1024 * 1024;
|
|
331
|
+
// A held exec cannot be allowed to turn the response stream into an unbounded
|
|
332
|
+
// staging area. One maximum-sized frame plus its envelope is enough to retain
|
|
333
|
+
// a complete frame while parser backpressure is active; additional input is a
|
|
334
|
+
// protocol failure rather than silently dropping raw progress.
|
|
335
|
+
const CURSOR_MAX_PENDING_SERVER_BYTES = CURSOR_MAX_GRPC_MESSAGE_LENGTH + 5;
|
|
336
|
+
// The conversation blob store is a content-addressed cache written by BOTH
|
|
337
|
+
// sides: request construction stores one blob per history message plus the
|
|
338
|
+
// per-turn structures, and the server stores its own state through `setBlob`.
|
|
339
|
+
// Bound it by bytes only. A separate entry ceiling was below the working set of
|
|
340
|
+
// an ordinary long session — a few hundred small blobs — so it rejected writes
|
|
341
|
+
// while the store held well under a megabyte.
|
|
342
|
+
const CURSOR_MAX_BLOB_STORE_BYTES = 64 * 1024 * 1024;
|
|
343
|
+
const CURSOR_BLOB_ID_BYTES = 32;
|
|
344
|
+
|
|
345
|
+
/** Exported for deterministic validation of fragmented Connect progress. */
|
|
346
|
+
export function isPlausibleCursorConnectProgressForTest(
|
|
347
|
+
bufferedLength: number,
|
|
348
|
+
flags: number,
|
|
349
|
+
messageLength?: number,
|
|
350
|
+
): boolean {
|
|
351
|
+
if (bufferedLength <= 0 || (flags & ~CONNECT_END_STREAM_FLAG) !== 0) return false;
|
|
352
|
+
if (bufferedLength < 5) return true;
|
|
353
|
+
return messageLength !== undefined && messageLength <= CURSOR_MAX_GRPC_MESSAGE_LENGTH;
|
|
354
|
+
}
|
|
355
|
+
const CURSOR_MAX_GRPC_ERROR_MESSAGE_LENGTH = 4096;
|
|
356
|
+
const CURSOR_EXEC_DEADLINE_MULTIPLIER = 4;
|
|
357
|
+
const CURSOR_MIN_EXEC_DEADLINE_MS = 100;
|
|
358
|
+
|
|
359
|
+
interface CursorPendingChunk {
|
|
360
|
+
bytes: Buffer;
|
|
361
|
+
offset: number;
|
|
362
|
+
next: CursorPendingChunk | null;
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
/**
|
|
366
|
+
* Bounded response staging for Connect frames. Incoming HTTP/2 chunks are
|
|
367
|
+
* retained by reference and consumed from the head; a frame split across
|
|
368
|
+
* chunks is copied once for protobuf decoding instead of repeatedly growing a
|
|
369
|
+
* single Buffer with Buffer.concat.
|
|
370
|
+
*/
|
|
371
|
+
class CursorPendingBuffer {
|
|
372
|
+
#head: CursorPendingChunk | null = null;
|
|
373
|
+
#tail: CursorPendingChunk | null = null;
|
|
374
|
+
#byteLength = 0;
|
|
375
|
+
#lookup: {
|
|
376
|
+
logicalOffset: number;
|
|
377
|
+
chunk: CursorPendingChunk;
|
|
378
|
+
chunkOffset: number;
|
|
379
|
+
} | null = null;
|
|
380
|
+
|
|
381
|
+
get length(): number {
|
|
382
|
+
return this.#byteLength;
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
append(bytes: Uint8Array): void {
|
|
386
|
+
if (bytes.length === 0) return;
|
|
387
|
+
const chunk: CursorPendingChunk = {
|
|
388
|
+
bytes: Buffer.isBuffer(bytes) ? bytes : Buffer.from(bytes),
|
|
389
|
+
offset: 0,
|
|
390
|
+
next: null,
|
|
391
|
+
};
|
|
392
|
+
if (this.#tail) this.#tail.next = chunk;
|
|
393
|
+
else this.#head = chunk;
|
|
394
|
+
this.#tail = chunk;
|
|
395
|
+
this.#byteLength += chunk.bytes.length;
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
clear(): void {
|
|
399
|
+
this.#head = null;
|
|
400
|
+
this.#tail = null;
|
|
401
|
+
this.#byteLength = 0;
|
|
402
|
+
this.#lookup = null;
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
consume(length: number): void {
|
|
406
|
+
if (!Number.isInteger(length) || length < 0 || length > this.#byteLength) {
|
|
407
|
+
throw new RangeError(`Cannot consume ${length} bytes from a ${this.#byteLength}-byte buffer`);
|
|
408
|
+
}
|
|
409
|
+
let remaining = length;
|
|
410
|
+
while (remaining > 0) {
|
|
411
|
+
const chunk = this.#head;
|
|
412
|
+
if (!chunk) throw new RangeError("Pending buffer ended while consuming bytes");
|
|
413
|
+
const available = chunk.bytes.length - chunk.offset;
|
|
414
|
+
const consumed = Math.min(remaining, available);
|
|
415
|
+
chunk.offset += consumed;
|
|
416
|
+
remaining -= consumed;
|
|
417
|
+
if (chunk.offset === chunk.bytes.length) {
|
|
418
|
+
this.#head = chunk.next;
|
|
419
|
+
chunk.next = null;
|
|
420
|
+
if (!this.#head) this.#tail = null;
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
this.#byteLength -= length;
|
|
424
|
+
this.#lookup = null;
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
#locate(offset: number): { chunk: CursorPendingChunk; chunkOffset: number } {
|
|
428
|
+
if (!Number.isInteger(offset) || offset < 0 || offset >= this.#byteLength) {
|
|
429
|
+
throw new RangeError(`Pending buffer offset ${offset} is outside ${this.#byteLength} bytes`);
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
let chunk: CursorPendingChunk | null;
|
|
433
|
+
let chunkOffset: number;
|
|
434
|
+
let logicalOffset: number;
|
|
435
|
+
if (this.#lookup && offset >= this.#lookup.logicalOffset) {
|
|
436
|
+
chunk = this.#lookup.chunk;
|
|
437
|
+
chunkOffset = this.#lookup.chunkOffset;
|
|
438
|
+
logicalOffset = this.#lookup.logicalOffset;
|
|
439
|
+
} else {
|
|
440
|
+
chunk = this.#head;
|
|
441
|
+
chunkOffset = chunk?.offset ?? 0;
|
|
442
|
+
logicalOffset = 0;
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
while (chunk) {
|
|
446
|
+
const available = chunk.bytes.length - chunkOffset;
|
|
447
|
+
if (offset < logicalOffset + available) {
|
|
448
|
+
this.#lookup = { logicalOffset: offset, chunk, chunkOffset: chunkOffset + (offset - logicalOffset) };
|
|
449
|
+
return { chunk, chunkOffset: chunkOffset + (offset - logicalOffset) };
|
|
450
|
+
}
|
|
451
|
+
logicalOffset += available;
|
|
452
|
+
chunk = chunk.next;
|
|
453
|
+
chunkOffset = chunk?.offset ?? 0;
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
throw new RangeError(`Pending buffer offset ${offset} is outside ${this.#byteLength} bytes`);
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
byteAt(offset: number): number {
|
|
460
|
+
const location = this.#locate(offset);
|
|
461
|
+
return location.chunk.bytes[location.chunkOffset];
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
readUInt32BE(offset: number): number {
|
|
465
|
+
if (!Number.isInteger(offset) || offset < 0 || offset + 4 > this.#byteLength) {
|
|
466
|
+
throw new RangeError(`Cannot read a 32-bit value at offset ${offset}`);
|
|
467
|
+
}
|
|
468
|
+
return (
|
|
469
|
+
((this.byteAt(offset) << 24) |
|
|
470
|
+
(this.byteAt(offset + 1) << 16) |
|
|
471
|
+
(this.byteAt(offset + 2) << 8) |
|
|
472
|
+
this.byteAt(offset + 3)) >>>
|
|
473
|
+
0
|
|
474
|
+
);
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
subarray(offset: number, length: number): Buffer {
|
|
478
|
+
if (!Number.isInteger(length) || length < 0 || offset < 0 || offset + length > this.#byteLength) {
|
|
479
|
+
throw new RangeError(`Cannot slice ${length} bytes at offset ${offset}`);
|
|
480
|
+
}
|
|
481
|
+
if (length === 0) return Buffer.alloc(0);
|
|
482
|
+
const first = this.#locate(offset);
|
|
483
|
+
const contiguous = first.chunk.bytes.length - first.chunkOffset;
|
|
484
|
+
if (length <= contiguous) return first.chunk.bytes.subarray(first.chunkOffset, first.chunkOffset + length);
|
|
485
|
+
|
|
486
|
+
const result = Buffer.allocUnsafe(length);
|
|
487
|
+
let written = 0;
|
|
488
|
+
let chunk: CursorPendingChunk | null = first.chunk;
|
|
489
|
+
let chunkOffset = first.chunkOffset;
|
|
490
|
+
while (chunk && written < length) {
|
|
491
|
+
const available = Math.min(length - written, chunk.bytes.length - chunkOffset);
|
|
492
|
+
chunk.bytes.copy(result, written, chunkOffset, chunkOffset + available);
|
|
493
|
+
written += available;
|
|
494
|
+
chunk = chunk.next;
|
|
495
|
+
chunkOffset = chunk?.offset ?? 0;
|
|
496
|
+
}
|
|
497
|
+
return result;
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
function cursorAbortError(signal: AbortSignal): Error {
|
|
502
|
+
const reason = signal.reason;
|
|
503
|
+
if (reason instanceof Error) {
|
|
504
|
+
// Normalize the default AbortError DOMException Bun supplies when abort()
|
|
505
|
+
// runs without a custom reason: Cursor's established terminal text is
|
|
506
|
+
// "Request was aborted", and the generic-abort matcher keys on it. Keep
|
|
507
|
+
// custom AbortError diagnostics intact; the name alone does not prove the
|
|
508
|
+
// caller omitted a reason.
|
|
509
|
+
if (
|
|
510
|
+
reason.name === "AbortError" &&
|
|
511
|
+
(reason.message === "The operation was aborted." || reason.message === "This operation was aborted")
|
|
512
|
+
) {
|
|
513
|
+
return new Error("Request was aborted");
|
|
514
|
+
}
|
|
515
|
+
return reason;
|
|
516
|
+
}
|
|
517
|
+
return new Error("Request was aborted");
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
/** Marker failures must escape resolveExecHandler instead of becoming a late wire response. */
|
|
521
|
+
class CursorExecAdmissionClosedError extends Error {
|
|
522
|
+
constructor(message = "Cursor non-abortable exec was marked after wrapper terminalization") {
|
|
523
|
+
super(message);
|
|
524
|
+
this.name = "CursorExecAdmissionClosedError";
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
/** Exported for deterministic coverage of the Cursor exec-budget derivation. */
|
|
529
|
+
export function cursorExecDeadlineMsForTest(idleTimeoutMs: number | undefined): number {
|
|
530
|
+
// A non-positive idle override explicitly DISABLES the transport watchdog;
|
|
531
|
+
// it must not collapse the exec deadline to the minimum clamp. Treat it
|
|
532
|
+
// like the normal 120-second input so disabling transport watching cannot
|
|
533
|
+
// make local tools stricter than the default (for example, bash's 300s).
|
|
534
|
+
if (idleTimeoutMs === undefined || idleTimeoutMs <= 0) {
|
|
535
|
+
return 120_000 * CURSOR_EXEC_DEADLINE_MULTIPLIER;
|
|
536
|
+
}
|
|
537
|
+
return Math.max(CURSOR_MIN_EXEC_DEADLINE_MS, idleTimeoutMs * CURSOR_EXEC_DEADLINE_MULTIPLIER);
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
/** Settlement proof for a started non-abortable Cursor exec. */
|
|
541
|
+
export interface CursorNonAbortableSettlement {
|
|
542
|
+
/** Resolves when the marked mutation settles; never rejects. */
|
|
543
|
+
settled: Promise<void>;
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
function runWithCursorExecDeadline<T>(
|
|
547
|
+
operation: (signal: AbortSignal, markNonAbortable: () => void) => Promise<T>,
|
|
548
|
+
signal: AbortSignal | undefined,
|
|
549
|
+
deadlineMs: number,
|
|
550
|
+
onNonAbortableStarted?: (settlement: CursorNonAbortableSettlement) => void,
|
|
551
|
+
onOperationFinished?: () => void,
|
|
552
|
+
onWrapperFinished?: () => void,
|
|
553
|
+
onControllerReady?: (abort: (reason?: Error) => void) => void,
|
|
554
|
+
transportTerminated?: () => boolean,
|
|
555
|
+
): Promise<T> {
|
|
556
|
+
const result = Promise.withResolvers<T>();
|
|
557
|
+
const operationCompletion = Promise.withResolvers<void>();
|
|
558
|
+
operationCompletion.promise.catch(() => {});
|
|
559
|
+
const controller = new AbortController();
|
|
560
|
+
const abortController = (reason?: Error): void => {
|
|
561
|
+
if (!controller.signal.aborted) controller.abort(reason);
|
|
562
|
+
};
|
|
563
|
+
let settled = false;
|
|
564
|
+
let nonAbortableStarted = false;
|
|
565
|
+
let abortError: Error | undefined;
|
|
566
|
+
let timer: NodeJS.Timeout | undefined;
|
|
567
|
+
|
|
568
|
+
const cleanup = () => {
|
|
569
|
+
if (timer) clearTimeout(timer);
|
|
570
|
+
if (signal) signal.removeEventListener("abort", onAbort);
|
|
571
|
+
};
|
|
572
|
+
const settle = (settlement: () => void) => {
|
|
573
|
+
if (settled) return;
|
|
574
|
+
settled = true;
|
|
575
|
+
cleanup();
|
|
576
|
+
onWrapperFinished?.();
|
|
577
|
+
settlement();
|
|
578
|
+
};
|
|
579
|
+
const onAbort = () => {
|
|
580
|
+
if (signal) {
|
|
581
|
+
abortError = cursorAbortError(signal);
|
|
582
|
+
abortController(abortError);
|
|
583
|
+
if (!nonAbortableStarted) settle(() => result.reject(abortError!));
|
|
584
|
+
}
|
|
585
|
+
};
|
|
586
|
+
onControllerReady?.(abortController);
|
|
587
|
+
|
|
588
|
+
if (signal?.aborted) {
|
|
589
|
+
abortController(cursorAbortError(signal));
|
|
590
|
+
settle(() => result.reject(cursorAbortError(signal)));
|
|
591
|
+
onOperationFinished?.();
|
|
592
|
+
return result.promise;
|
|
593
|
+
}
|
|
594
|
+
if (signal) signal.addEventListener("abort", onAbort, { once: true });
|
|
595
|
+
timer = setTimeout(() => {
|
|
596
|
+
// The deadline cannot forcibly cancel the handler's local work; it aborts
|
|
597
|
+
// the per-exec signal so cooperative tools stop, and the rejection below
|
|
598
|
+
// still bounds how long this turn waits for the handler promise.
|
|
599
|
+
abortError = new Error(`Cursor local exec exceeded its ${deadlineMs}ms deadline`);
|
|
600
|
+
abortController(abortError);
|
|
601
|
+
if (!nonAbortableStarted) settle(() => result.reject(abortError!));
|
|
602
|
+
}, deadlineMs);
|
|
603
|
+
void operation(controller.signal, () => {
|
|
604
|
+
if (nonAbortableStarted) return;
|
|
605
|
+
if (settled || transportTerminated?.()) {
|
|
606
|
+
throw new CursorExecAdmissionClosedError();
|
|
607
|
+
}
|
|
608
|
+
nonAbortableStarted = true;
|
|
609
|
+
onNonAbortableStarted?.({
|
|
610
|
+
settled: operationCompletion.promise,
|
|
611
|
+
});
|
|
612
|
+
}).then(
|
|
613
|
+
value => {
|
|
614
|
+
operationCompletion.resolve();
|
|
615
|
+
onOperationFinished?.();
|
|
616
|
+
settle(() => (abortError ? result.reject(abortError) : result.resolve(value)));
|
|
617
|
+
},
|
|
618
|
+
error => {
|
|
619
|
+
operationCompletion.resolve();
|
|
620
|
+
onOperationFinished?.();
|
|
621
|
+
settle(() => result.reject(abortError ?? error));
|
|
622
|
+
},
|
|
623
|
+
);
|
|
624
|
+
return result.promise;
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
/** Exported for production-bridge coverage of non-abortable terminal ordering. */
|
|
628
|
+
export function runWithCursorExecDeadlineForTest<T>(
|
|
629
|
+
operation: (signal: AbortSignal, markNonAbortable: () => void) => Promise<T>,
|
|
630
|
+
signal: AbortSignal | undefined,
|
|
631
|
+
deadlineMs: number,
|
|
632
|
+
): Promise<T> {
|
|
633
|
+
return runWithCursorExecDeadline(operation, signal, deadlineMs);
|
|
634
|
+
}
|
|
223
635
|
|
|
224
636
|
interface CursorLogEntry {
|
|
225
637
|
ts: number;
|
|
@@ -256,201 +668,253 @@ function frameConnectMessage(data: Uint8Array, flags = 0): Buffer {
|
|
|
256
668
|
return frame;
|
|
257
669
|
}
|
|
258
670
|
|
|
259
|
-
function
|
|
260
|
-
|
|
261
|
-
const payload = JSON.parse(new TextDecoder().decode(data));
|
|
262
|
-
const error = payload?.error;
|
|
263
|
-
if (error) {
|
|
264
|
-
const code = typeof error.code === "string" ? error.code : "unknown";
|
|
265
|
-
const message = typeof error.message === "string" ? error.message : "Unknown error";
|
|
266
|
-
return new Error(`Connect error ${code}: ${message}`);
|
|
267
|
-
}
|
|
268
|
-
return null;
|
|
269
|
-
} catch {
|
|
270
|
-
return new Error("Failed to parse Connect end stream");
|
|
271
|
-
}
|
|
671
|
+
function isClosedCursorRequest(request: http2.ClientHttp2Stream): boolean {
|
|
672
|
+
return request.closed || request.destroyed || request.writableEnded || request.writableFinished;
|
|
272
673
|
}
|
|
273
674
|
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
}
|
|
675
|
+
const CURSOR_WRITE_DRAIN_TIMEOUT_MS = 5_000;
|
|
676
|
+
const CURSOR_MAX_PENDING_SHELL_WRITE_BYTES = 1024 * 1024;
|
|
677
|
+
const pendingCursorWrites = new WeakMap<object, Set<Promise<void>>>();
|
|
678
|
+
const cursorWriteErrors = new WeakMap<object, unknown>();
|
|
279
679
|
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
#shellGates = new Set<() => void>();
|
|
290
|
-
#request: http2.ClientHttp2Stream;
|
|
291
|
-
#stopHeartbeat: () => void;
|
|
292
|
-
#onSuccess: () => void;
|
|
293
|
-
#onFailure: (error: Error) => void;
|
|
294
|
-
#drainTimer: NodeJS.Timeout | null = null;
|
|
295
|
-
#drainTimeoutMs: number | undefined;
|
|
296
|
-
|
|
297
|
-
constructor(
|
|
298
|
-
request: http2.ClientHttp2Stream,
|
|
299
|
-
stopHeartbeat: () => void,
|
|
300
|
-
onSuccess: () => void,
|
|
301
|
-
onFailure: (error: Error) => void,
|
|
302
|
-
drainTimeoutMs: number | undefined,
|
|
303
|
-
) {
|
|
304
|
-
this.#request = request;
|
|
305
|
-
this.#stopHeartbeat = stopHeartbeat;
|
|
306
|
-
this.#onSuccess = onSuccess;
|
|
307
|
-
this.#onFailure = onFailure;
|
|
308
|
-
this.#drainTimeoutMs = drainTimeoutMs;
|
|
680
|
+
function closeStalledCursorRequest(request: http2.ClientHttp2Stream): void {
|
|
681
|
+
// A request whose peer stopped reading may never invoke a write callback. Close
|
|
682
|
+
// and destroy both sides of the stream so the bounded drain cannot leave a
|
|
683
|
+
// live HTTP/2 transport behind. Test writers may only implement one of these
|
|
684
|
+
// methods, hence the defensive checks.
|
|
685
|
+
try {
|
|
686
|
+
request.close?.();
|
|
687
|
+
} catch {
|
|
688
|
+
// Teardown is best effort; the timeout remains the authoritative result.
|
|
309
689
|
}
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
690
|
+
try {
|
|
691
|
+
request.destroy?.();
|
|
692
|
+
} catch {
|
|
693
|
+
// Teardown is best effort; the timeout remains the authoritative result.
|
|
313
694
|
}
|
|
695
|
+
}
|
|
314
696
|
|
|
315
|
-
|
|
316
|
-
|
|
697
|
+
/** Await request-side END_STREAM under the same bounded teardown contract used by Cursor streams. */
|
|
698
|
+
export async function endCursorRequestForTest(
|
|
699
|
+
request: Pick<http2.ClientHttp2Stream, "end">,
|
|
700
|
+
timeoutMs = 100,
|
|
701
|
+
): Promise<boolean> {
|
|
702
|
+
const completion = Promise.withResolvers<boolean>();
|
|
703
|
+
let settled = false;
|
|
704
|
+
const settle = (value: boolean): void => {
|
|
705
|
+
if (settled) return;
|
|
706
|
+
settled = true;
|
|
707
|
+
completion.resolve(value);
|
|
708
|
+
};
|
|
709
|
+
const timer = setTimeout(() => settle(false), timeoutMs);
|
|
710
|
+
try {
|
|
711
|
+
request.end(() => settle(true));
|
|
712
|
+
} catch {
|
|
713
|
+
settle(false);
|
|
317
714
|
}
|
|
715
|
+
const completed = await completion.promise;
|
|
716
|
+
clearTimeout(timer);
|
|
717
|
+
return completed;
|
|
718
|
+
}
|
|
318
719
|
|
|
319
|
-
|
|
320
|
-
|
|
720
|
+
/** Wait until every frame accepted by a request has reached the HTTP/2 writer. */
|
|
721
|
+
async function waitForCursorWrites(
|
|
722
|
+
request: http2.ClientHttp2Stream | null,
|
|
723
|
+
timeoutMs = CURSOR_WRITE_DRAIN_TIMEOUT_MS,
|
|
724
|
+
onTimeout?: (error: Error) => void,
|
|
725
|
+
): Promise<void> {
|
|
726
|
+
if (!request) return;
|
|
727
|
+
const pending = pendingCursorWrites.get(request);
|
|
728
|
+
if (!pending) return;
|
|
729
|
+
const boundedTimeoutMs = Math.max(1, timeoutMs);
|
|
730
|
+
const deadline = Date.now() + boundedTimeoutMs;
|
|
731
|
+
let timeout: NodeJS.Timeout | undefined;
|
|
732
|
+
try {
|
|
733
|
+
while (pending.size > 0) {
|
|
734
|
+
const writesDone = Promise.all([...pending]);
|
|
735
|
+
// The timeout race may win while one or more writes later reject. Keep a
|
|
736
|
+
// rejection handler attached so late callback errors never become unhandled.
|
|
737
|
+
writesDone.catch(() => {});
|
|
738
|
+
const remainingMs = deadline - Date.now();
|
|
739
|
+
if (remainingMs <= 0) {
|
|
740
|
+
const error = new Error(`Cursor request write drain timed out after ${boundedTimeoutMs}ms`);
|
|
741
|
+
try {
|
|
742
|
+
onTimeout?.(error);
|
|
743
|
+
} catch {
|
|
744
|
+
// The transport teardown callback is best effort.
|
|
745
|
+
}
|
|
746
|
+
if (!onTimeout) closeStalledCursorRequest(request);
|
|
747
|
+
throw error;
|
|
748
|
+
}
|
|
749
|
+
const timeoutDeferred = Promise.withResolvers<never>();
|
|
750
|
+
timeoutDeferred.promise.catch(() => {});
|
|
751
|
+
timeout = setTimeout(() => {
|
|
752
|
+
const error = new Error(`Cursor request write drain timed out after ${boundedTimeoutMs}ms`);
|
|
753
|
+
// Reject first so teardown callbacks that synchronously complete or
|
|
754
|
+
// fail a write cannot replace the deterministic timeout outcome.
|
|
755
|
+
timeoutDeferred.reject(error);
|
|
756
|
+
try {
|
|
757
|
+
onTimeout?.(error);
|
|
758
|
+
} catch {
|
|
759
|
+
// The transport teardown callback is best effort.
|
|
760
|
+
}
|
|
761
|
+
if (!onTimeout) closeStalledCursorRequest(request);
|
|
762
|
+
}, remainingMs);
|
|
763
|
+
const timeoutPromise = timeoutDeferred.promise;
|
|
764
|
+
await Promise.race([writesDone, timeoutPromise]);
|
|
765
|
+
if (timeout) {
|
|
766
|
+
clearTimeout(timeout);
|
|
767
|
+
timeout = undefined;
|
|
768
|
+
}
|
|
769
|
+
const writeError = cursorWriteErrors.get(request);
|
|
770
|
+
if (writeError !== undefined) throw writeError;
|
|
771
|
+
}
|
|
772
|
+
const writeError = cursorWriteErrors.get(request);
|
|
773
|
+
if (writeError !== undefined) throw writeError;
|
|
774
|
+
} finally {
|
|
775
|
+
if (timeout) clearTimeout(timeout);
|
|
776
|
+
pendingCursorWrites.delete(request);
|
|
777
|
+
cursorWriteErrors.delete(request);
|
|
321
778
|
}
|
|
779
|
+
}
|
|
322
780
|
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
781
|
+
/** Exported for deterministic coverage of successful writer teardown ordering. */
|
|
782
|
+
export function waitForCursorWritesForTest(request: http2.ClientHttp2Stream | null, timeoutMs?: number): Promise<void> {
|
|
783
|
+
return waitForCursorWrites(request, timeoutMs);
|
|
784
|
+
}
|
|
326
785
|
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
786
|
+
async function waitForCursorWriteDrain(
|
|
787
|
+
request: http2.ClientHttp2Stream,
|
|
788
|
+
timeoutMs = CURSOR_WRITE_DRAIN_TIMEOUT_MS,
|
|
789
|
+
): Promise<void> {
|
|
790
|
+
if (isClosedCursorRequest(request)) throw new Error("Cursor request closed while waiting for write backpressure");
|
|
791
|
+
const settled = Promise.withResolvers<void>();
|
|
792
|
+
const onDrain = () => settled.resolve();
|
|
793
|
+
const onClose = () => settled.reject(new Error("Cursor request closed while waiting for write backpressure"));
|
|
794
|
+
const onError = (error: unknown) => settled.reject(error);
|
|
795
|
+
request.once("drain", onDrain);
|
|
796
|
+
request.once("close", onClose);
|
|
797
|
+
request.once("error", onError);
|
|
798
|
+
const timeout = setTimeout(
|
|
799
|
+
() => {
|
|
800
|
+
const error = new Error(`Cursor request write backpressure timed out after ${timeoutMs}ms`);
|
|
801
|
+
settled.reject(error);
|
|
802
|
+
closeStalledCursorRequest(request);
|
|
803
|
+
},
|
|
804
|
+
Math.max(1, timeoutMs),
|
|
805
|
+
);
|
|
806
|
+
try {
|
|
807
|
+
await settled.promise;
|
|
808
|
+
} finally {
|
|
809
|
+
clearTimeout(timeout);
|
|
810
|
+
request.removeListener("drain", onDrain);
|
|
811
|
+
request.removeListener("close", onClose);
|
|
812
|
+
request.removeListener("error", onError);
|
|
331
813
|
}
|
|
814
|
+
}
|
|
332
815
|
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
return () => {};
|
|
337
|
-
}
|
|
338
|
-
this.#shellGates.add(close);
|
|
339
|
-
return () => this.#shellGates.delete(close);
|
|
340
|
-
}
|
|
816
|
+
export function waitForCursorWriteDrainForTest(request: http2.ClientHttp2Stream, timeoutMs?: number): Promise<void> {
|
|
817
|
+
return waitForCursorWriteDrain(request, timeoutMs);
|
|
818
|
+
}
|
|
341
819
|
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
820
|
+
/**
|
|
821
|
+
* Late exec/stream handlers can finish after the bounded settlement fence has
|
|
822
|
+
* closed the HTTP/2 request. Treat those writes as dropped transport output;
|
|
823
|
+
* never let a synchronous write-after-end error escape into the process.
|
|
824
|
+
*/
|
|
825
|
+
function writeCursorFrame(request: http2.ClientHttp2Stream, frame: Uint8Array): boolean {
|
|
826
|
+
if (isClosedCursorRequest(request)) return false;
|
|
827
|
+
let completed = false;
|
|
828
|
+
const completion = Promise.withResolvers<void>();
|
|
829
|
+
const pending = pendingCursorWrites.get(request) ?? new Set<Promise<void>>();
|
|
830
|
+
pendingCursorWrites.set(request, pending);
|
|
831
|
+
// The final request drain observes this rejection, but an asynchronous writer
|
|
832
|
+
// callback can run before that drain starts. Mark it handled immediately so a
|
|
833
|
+
// late transport error cannot surface as an unhandled rejection in the gap.
|
|
834
|
+
completion.promise.catch(() => {});
|
|
835
|
+
pending.add(completion.promise);
|
|
836
|
+
const finish = (error?: unknown) => {
|
|
837
|
+
if (completed) return;
|
|
838
|
+
completed = true;
|
|
839
|
+
pending.delete(completion.promise);
|
|
840
|
+
if (error != null && !cursorWriteErrors.has(request)) cursorWriteErrors.set(request, error);
|
|
841
|
+
if (typeof request.removeListener === "function") {
|
|
842
|
+
request.removeListener("close", onClose);
|
|
843
|
+
request.removeListener("error", finish);
|
|
844
|
+
}
|
|
845
|
+
if (error == null) completion.resolve();
|
|
846
|
+
else completion.reject(error);
|
|
847
|
+
};
|
|
848
|
+
const onClose = () => finish(new Error("Cursor request closed before write completed"));
|
|
849
|
+
try {
|
|
850
|
+
// The real HTTP/2 stream always exposes EventEmitter methods. Keep the
|
|
851
|
+
// test seam tolerant of a minimal writer stub as well.
|
|
852
|
+
if (typeof request.once === "function") {
|
|
853
|
+
request.once("close", onClose);
|
|
854
|
+
request.once("error", finish);
|
|
855
|
+
}
|
|
856
|
+
return request.write(frame, finish) !== false;
|
|
857
|
+
} catch (error) {
|
|
858
|
+
if (isClosedCursorRequest(request)) {
|
|
859
|
+
finish();
|
|
860
|
+
return false;
|
|
861
|
+
}
|
|
862
|
+
const code = (error as NodeJS.ErrnoException).code;
|
|
863
|
+
if (
|
|
864
|
+
code === "ERR_STREAM_WRITE_AFTER_END" ||
|
|
865
|
+
code === "ERR_HTTP2_INVALID_STREAM" ||
|
|
866
|
+
code === "ERR_HTTP2_STREAM_CLOSED"
|
|
867
|
+
) {
|
|
868
|
+
finish();
|
|
869
|
+
return false;
|
|
870
|
+
}
|
|
871
|
+
finish(error);
|
|
872
|
+
throw error;
|
|
345
873
|
}
|
|
874
|
+
}
|
|
346
875
|
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
876
|
+
/** Exported for deterministic coverage of the post-fence write race. */
|
|
877
|
+
export function writeCursorFrameForTest(request: http2.ClientHttp2Stream, frame: Uint8Array): boolean {
|
|
878
|
+
return writeCursorFrame(request, frame);
|
|
879
|
+
}
|
|
351
880
|
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
return taskFactory();
|
|
357
|
-
})
|
|
358
|
-
: taskFactory();
|
|
359
|
-
this.#hasAdmittedTask = true;
|
|
360
|
-
this.#taskChain = orderedTask.then(
|
|
361
|
-
() => {},
|
|
362
|
-
error => {
|
|
363
|
-
this.fail(error instanceof Error ? error : new Error(String(error)));
|
|
364
|
-
},
|
|
365
|
-
);
|
|
366
|
-
this.#tasks.add(orderedTask);
|
|
367
|
-
void orderedTask.then(
|
|
368
|
-
() => this.#tasks.delete(orderedTask),
|
|
369
|
-
() => this.#tasks.delete(orderedTask),
|
|
370
|
-
);
|
|
371
|
-
return orderedTask;
|
|
372
|
-
}
|
|
373
|
-
|
|
374
|
-
turnEnded(): void {
|
|
375
|
-
if (this.#state !== "open") return;
|
|
376
|
-
this.#state = "draining";
|
|
377
|
-
this.#stopHeartbeat();
|
|
378
|
-
if (this.#drainTimeoutMs !== undefined && this.#drainTimeoutMs > 0) {
|
|
379
|
-
this.#drainTimer = setTimeout(() => {
|
|
380
|
-
this.fail(new Error(`Cursor admitted work drain timed out after ${this.#drainTimeoutMs}ms`));
|
|
381
|
-
}, this.#drainTimeoutMs);
|
|
382
|
-
}
|
|
383
|
-
void Promise.all([...this.#tasks]).then(
|
|
384
|
-
() => {
|
|
385
|
-
if (this.#state !== "draining") return;
|
|
386
|
-
this.#drain(() => {
|
|
387
|
-
if (this.#state !== "draining") return;
|
|
388
|
-
if (this.#drainTimer) {
|
|
389
|
-
clearTimeout(this.#drainTimer);
|
|
390
|
-
this.#drainTimer = null;
|
|
391
|
-
}
|
|
392
|
-
this.#state = "succeeded";
|
|
393
|
-
this.#onSuccess();
|
|
394
|
-
});
|
|
395
|
-
},
|
|
396
|
-
error => this.fail(error instanceof Error ? error : new Error(String(error))),
|
|
397
|
-
);
|
|
398
|
-
}
|
|
881
|
+
interface CursorRequestWriter extends http2.ClientHttp2Stream {
|
|
882
|
+
isActive(): boolean;
|
|
883
|
+
registerShellGate(close: () => void): () => void;
|
|
884
|
+
}
|
|
399
885
|
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
clearTimeout(this.#drainTimer);
|
|
406
|
-
this.#drainTimer = null;
|
|
407
|
-
}
|
|
408
|
-
this.#stopHeartbeat();
|
|
409
|
-
for (const close of this.#shellGates) close();
|
|
410
|
-
this.#shellGates.clear();
|
|
411
|
-
this.#frames = [];
|
|
412
|
-
this.#writing = false;
|
|
413
|
-
this.#releaseDrains();
|
|
414
|
-
this.#request.close();
|
|
415
|
-
this.#onFailure(error);
|
|
416
|
-
}
|
|
417
|
-
|
|
418
|
-
#writeNext(): void {
|
|
419
|
-
if (this.#writing || !this.isActive()) return;
|
|
420
|
-
const frame = this.#frames.shift();
|
|
421
|
-
if (!frame) {
|
|
422
|
-
this.#releaseDrains();
|
|
423
|
-
return;
|
|
886
|
+
function parseConnectEndStream(data: Uint8Array): Error | null {
|
|
887
|
+
try {
|
|
888
|
+
const payload = JSON.parse(new TextDecoder().decode(data));
|
|
889
|
+
if (!payload || typeof payload !== "object" || Array.isArray(payload)) {
|
|
890
|
+
return new Error("Invalid Connect end stream envelope");
|
|
424
891
|
}
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
this.#request.write(frame, error => {
|
|
428
|
-
this.#writing = false;
|
|
429
|
-
if (error) {
|
|
430
|
-
this.fail(error);
|
|
431
|
-
return;
|
|
432
|
-
}
|
|
433
|
-
this.#writeNext();
|
|
434
|
-
});
|
|
435
|
-
} catch (error) {
|
|
436
|
-
this.#writing = false;
|
|
437
|
-
this.fail(error instanceof Error ? error : new Error(String(error)));
|
|
892
|
+
if ("error" in payload && (!payload.error || typeof payload.error !== "object" || Array.isArray(payload.error))) {
|
|
893
|
+
return new Error("Invalid Connect end stream error envelope");
|
|
438
894
|
}
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
895
|
+
const error = payload.error as { code?: unknown; message?: unknown } | undefined;
|
|
896
|
+
if (error) {
|
|
897
|
+
const code =
|
|
898
|
+
typeof error.code === "string" ? error.code.slice(0, CURSOR_MAX_GRPC_ERROR_MESSAGE_LENGTH) : "unknown";
|
|
899
|
+
const message =
|
|
900
|
+
typeof error.message === "string"
|
|
901
|
+
? error.message.slice(0, CURSOR_MAX_GRPC_ERROR_MESSAGE_LENGTH)
|
|
902
|
+
: "Unknown error";
|
|
903
|
+
return new Error(`Connect error ${code}: ${message}`);
|
|
445
904
|
}
|
|
446
|
-
|
|
905
|
+
return null;
|
|
906
|
+
} catch {
|
|
907
|
+
return new Error("Failed to parse Connect end stream");
|
|
447
908
|
}
|
|
909
|
+
}
|
|
448
910
|
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
911
|
+
function decodeGrpcMessage(value: unknown): string {
|
|
912
|
+
const raw = typeof value === "string" ? value : value == null ? "" : String(value);
|
|
913
|
+
const boundedRaw = raw.slice(0, CURSOR_MAX_GRPC_ERROR_MESSAGE_LENGTH);
|
|
914
|
+
try {
|
|
915
|
+
return decodeURIComponent(boundedRaw).slice(0, CURSOR_MAX_GRPC_ERROR_MESSAGE_LENGTH);
|
|
916
|
+
} catch {
|
|
917
|
+
return boundedRaw;
|
|
454
918
|
}
|
|
455
919
|
}
|
|
456
920
|
|
|
@@ -628,12 +1092,35 @@ export function mapH2TransportError(error: unknown, baseUrl: string): unknown {
|
|
|
628
1092
|
);
|
|
629
1093
|
}
|
|
630
1094
|
|
|
1095
|
+
/** Whether a decoded server envelope carries a known semantic message. */
|
|
1096
|
+
function isMeaningfulCursorServerMessage(msg: AgentServerMessage): boolean {
|
|
1097
|
+
switch (msg.message.case) {
|
|
1098
|
+
case "interactionUpdate":
|
|
1099
|
+
return msg.message.value.message.case !== undefined;
|
|
1100
|
+
case "execServerMessage":
|
|
1101
|
+
return msg.message.value.message.case !== undefined;
|
|
1102
|
+
case "kvServerMessage":
|
|
1103
|
+
return msg.message.value.message.case !== undefined;
|
|
1104
|
+
case "conversationCheckpointUpdate":
|
|
1105
|
+
return true;
|
|
1106
|
+
default:
|
|
1107
|
+
return false;
|
|
1108
|
+
}
|
|
1109
|
+
}
|
|
1110
|
+
|
|
631
1111
|
export const streamCursor: StreamFunction<"cursor-agent"> = (
|
|
632
1112
|
model: Model<"cursor-agent">,
|
|
633
1113
|
context: Context,
|
|
634
1114
|
options?: CursorOptions,
|
|
635
1115
|
): AssistantMessageEventStream => {
|
|
636
1116
|
const stream = new AssistantMessageEventStream();
|
|
1117
|
+
// Cursor owns this watchdog, so the budget begins at streamCursor()
|
|
1118
|
+
// invocation—before system-prompt normalization/rule protobuf construction
|
|
1119
|
+
// as well as history/blob/request serialization.
|
|
1120
|
+
const firstEventStartedAt = Date.now();
|
|
1121
|
+
const idleTimeoutMs = options?.streamIdleTimeoutMs ?? getStreamIdleTimeoutMs();
|
|
1122
|
+
const firstEventTimeoutMs = options?.streamFirstEventTimeoutMs ?? getStreamFirstEventTimeoutMs(idleTimeoutMs);
|
|
1123
|
+
const endpointClass = (model.baseUrl || CURSOR_API_URL) === CURSOR_API_URL ? "canonical" : "custom";
|
|
637
1124
|
const requestContextRules = buildCursorRequestContextRules(context.systemPrompt);
|
|
638
1125
|
|
|
639
1126
|
(async () => {
|
|
@@ -660,58 +1147,405 @@ export const streamCursor: StreamFunction<"cursor-agent"> = (
|
|
|
660
1147
|
|
|
661
1148
|
let h2Client: http2.ClientHttp2Session | null = null;
|
|
662
1149
|
let h2Request: http2.ClientHttp2Stream | null = null;
|
|
663
|
-
|
|
1150
|
+
const shellGates = new Set<() => void>();
|
|
1151
|
+
let proxiedSocket: tls.TLSSocket | null = null;
|
|
664
1152
|
let heartbeatTimer: NodeJS.Timeout | null = null;
|
|
665
|
-
let
|
|
666
|
-
let
|
|
1153
|
+
let h2ClientErrorHandler: ((error: Error) => void) | undefined;
|
|
1154
|
+
let h2ClientCloseHandler: (() => void) | undefined;
|
|
1155
|
+
let h2RequestErrorHandler: ((error: Error) => void) | undefined;
|
|
1156
|
+
let h2RequestCloseHandler: (() => void) | undefined;
|
|
1157
|
+
let h2RequestAbortedHandler: (() => void) | undefined;
|
|
1158
|
+
let gracefulCloseCheckTimer: NodeJS.Timeout | undefined;
|
|
1159
|
+
let completedSuccessfully = false;
|
|
667
1160
|
const baseUrl = model.baseUrl || CURSOR_API_URL;
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
let
|
|
1161
|
+
const h2Completion = Promise.withResolvers<void>();
|
|
1162
|
+
h2Completion.promise.catch(() => {});
|
|
1163
|
+
let h2Settled = false;
|
|
1164
|
+
let h2Failure: unknown;
|
|
1165
|
+
let sawTurnEnded = false;
|
|
1166
|
+
let terminalAdmissionMode: "open" | "raw-eof" | "closed" = "open";
|
|
1167
|
+
let responseEnded = false;
|
|
1168
|
+
let queueDrained = false;
|
|
1169
|
+
let postTurnEndedCheckpointTimer: NodeJS.Timeout | undefined;
|
|
1170
|
+
let endStreamError: Error | null = null;
|
|
1171
|
+
const pendingBuffer = new CursorPendingBuffer();
|
|
1172
|
+
let bufferedObservationOffset = 0;
|
|
1173
|
+
let bufferedObservationTurnEnded = false;
|
|
1174
|
+
const closeTerminalAdmission = (pauseRequest = true): void => {
|
|
1175
|
+
terminalAdmissionMode = "closed";
|
|
1176
|
+
transportWatchdogClosed = true;
|
|
1177
|
+
if (transportWatchdog) {
|
|
1178
|
+
clearTimeout(transportWatchdog);
|
|
1179
|
+
transportWatchdog = null;
|
|
1180
|
+
}
|
|
1181
|
+
if (pauseRequest) h2Request?.pause();
|
|
1182
|
+
};
|
|
1183
|
+
const sealExecAdmissionAtRawEof = (): void => {
|
|
1184
|
+
if (terminalAdmissionMode !== "open") return;
|
|
1185
|
+
terminalAdmissionMode = "raw-eof";
|
|
1186
|
+
};
|
|
1187
|
+
const settleH2 = (error?: unknown): void => {
|
|
1188
|
+
if (h2Settled) return;
|
|
1189
|
+
h2Settled = true;
|
|
1190
|
+
if (error !== undefined) {
|
|
1191
|
+
h2Failure = mapH2TransportError(error, baseUrl);
|
|
1192
|
+
h2Completion.reject(h2Failure);
|
|
1193
|
+
} else {
|
|
1194
|
+
h2Completion.resolve();
|
|
1195
|
+
}
|
|
1196
|
+
};
|
|
1197
|
+
const hasCompleteBufferedFrame = (): boolean =>
|
|
1198
|
+
pendingBuffer.length >= 5 && pendingBuffer.length >= 5 + pendingBuffer.readUInt32BE(1);
|
|
1199
|
+
const hasPlausibleBufferedFrameProgress = (): boolean => {
|
|
1200
|
+
if (pendingBuffer.length === 0) return false;
|
|
1201
|
+
const flags = pendingBuffer.byteAt(0);
|
|
1202
|
+
return isPlausibleCursorConnectProgressForTest(
|
|
1203
|
+
pendingBuffer.length,
|
|
1204
|
+
flags,
|
|
1205
|
+
pendingBuffer.length >= 5 ? pendingBuffer.readUInt32BE(1) : undefined,
|
|
1206
|
+
);
|
|
1207
|
+
};
|
|
1208
|
+
const refreshPostTurnEndedGrace = (): void => {
|
|
1209
|
+
if (!postTurnEndedCheckpointTimer || !hasPlausibleBufferedFrameProgress()) return;
|
|
1210
|
+
clearTimeout(postTurnEndedCheckpointTimer);
|
|
1211
|
+
postTurnEndedCheckpointTimer = undefined;
|
|
1212
|
+
};
|
|
1213
|
+
const settleH2WhenReady = (): void => {
|
|
1214
|
+
if (terminalDrainMode) return;
|
|
1215
|
+
if (!queueDrained) return;
|
|
1216
|
+
if (hasCompleteBufferedFrame()) return;
|
|
1217
|
+
if (endStreamError) {
|
|
1218
|
+
settleBehindFence(() => settleH2(endStreamError));
|
|
1219
|
+
} else if (sawTurnEnded && responseEnded) {
|
|
1220
|
+
// A drained turnEnded is the successful terminal condition; Cursor
|
|
1221
|
+
// may leave the HTTP/2 response open after sending it.
|
|
1222
|
+
settleBehindFence(() => settleH2());
|
|
1223
|
+
} else if (sawTurnEnded && !postTurnEndedCheckpointTimer) {
|
|
1224
|
+
// Cursor may send a final conversation checkpoint immediately after
|
|
1225
|
+
// turnEnded without an END_STREAM frame. Give that non-executable
|
|
1226
|
+
// message a bounded grace window before publishing the terminal.
|
|
1227
|
+
postTurnEndedCheckpointTimer = setTimeout(() => {
|
|
1228
|
+
postTurnEndedCheckpointTimer = undefined;
|
|
1229
|
+
const request = h2Request;
|
|
1230
|
+
if (!request || isClosedCursorRequest(request)) {
|
|
1231
|
+
settleBehindFence(() => settleH2());
|
|
1232
|
+
return;
|
|
1233
|
+
}
|
|
1234
|
+
settleBehindFence(() => {
|
|
1235
|
+
localTransportCloseRequested = true;
|
|
1236
|
+
let finished = false;
|
|
1237
|
+
const finish = (): void => {
|
|
1238
|
+
if (finished) return;
|
|
1239
|
+
finished = true;
|
|
1240
|
+
closeStalledCursorRequest(request);
|
|
1241
|
+
settleH2();
|
|
1242
|
+
};
|
|
1243
|
+
const forceTimer = setTimeout(finish, 100);
|
|
1244
|
+
request.end(() => {
|
|
1245
|
+
clearTimeout(forceTimer);
|
|
1246
|
+
finish();
|
|
1247
|
+
});
|
|
1248
|
+
});
|
|
1249
|
+
}, 25);
|
|
1250
|
+
} else if (responseEnded) {
|
|
1251
|
+
settleBehindFence(() => settleH2(new Error("Cursor HTTP/2 stream ended before turnEnded")));
|
|
1252
|
+
}
|
|
1253
|
+
};
|
|
1254
|
+
let transportWatchdog: NodeJS.Timeout | null = null;
|
|
1255
|
+
let transportWatchdogClosed = false;
|
|
1256
|
+
let callerAbortError: Error | undefined;
|
|
1257
|
+
let pendingNonAbortableExec: CursorNonAbortableSettlement | undefined;
|
|
1258
|
+
let processingPausedForQueue = false;
|
|
1259
|
+
let localTransportCloseRequested = false;
|
|
1260
|
+
let transportTerminalized = false;
|
|
1261
|
+
let terminalDrainMode = false;
|
|
1262
|
+
let terminalDrain: (() => void) | undefined;
|
|
1263
|
+
let terminalDrainStarted = false;
|
|
1264
|
+
let execQueuePrefix: Promise<void> | undefined;
|
|
1265
|
+
let terminalPendingError: unknown;
|
|
1266
|
+
let terminalBoundarySeen = false;
|
|
1267
|
+
// Lookahead can validate turnEnded while an exec handler holds the normal
|
|
1268
|
+
// parser. Close new exec admission immediately, but leave the validated
|
|
1269
|
+
// prefix available for ordered processing once that handler settles.
|
|
1270
|
+
let terminalBoundaryObserved = false;
|
|
1271
|
+
// When lookahead observes turnEnded in a coalesced buffer, retain its byte
|
|
1272
|
+
// offset so processPendingBuffer can drain the validated prefix without
|
|
1273
|
+
// admitting executable frames from the tail after that boundary.
|
|
1274
|
+
let bufferedTerminalBoundaryOffset: number | undefined;
|
|
1275
|
+
let processPendingBuffer: (() => void) | undefined;
|
|
1276
|
+
let activeExecAbort: ((reason?: Error) => void) | undefined;
|
|
1277
|
+
const closeTransportLocally = (): void => {
|
|
1278
|
+
localTransportCloseRequested = true;
|
|
1279
|
+
h2Request?.close();
|
|
1280
|
+
h2Client?.close();
|
|
1281
|
+
proxiedSocket?.destroy();
|
|
1282
|
+
};
|
|
1283
|
+
const forceCloseTransport = (): void => {
|
|
1284
|
+
closeTransportLocally();
|
|
1285
|
+
try {
|
|
1286
|
+
h2Request?.destroy();
|
|
1287
|
+
} catch {
|
|
1288
|
+
// Teardown is best effort; the write-drain timeout remains authoritative.
|
|
1289
|
+
}
|
|
1290
|
+
try {
|
|
1291
|
+
h2Client?.destroy();
|
|
1292
|
+
} catch {
|
|
1293
|
+
// Teardown is best effort; the write-drain timeout remains authoritative.
|
|
1294
|
+
}
|
|
1295
|
+
proxiedSocket?.destroy();
|
|
1296
|
+
};
|
|
1297
|
+
// Terminal publication (caller abort, transport error, or stream end)
|
|
1298
|
+
// must wait for any started non-abortable mutation to settle: a network
|
|
1299
|
+
// reset mid-exec otherwise publishes the terminal and lets a retry start
|
|
1300
|
+
// while the filesystem mutation is still running. Non-abortable means the
|
|
1301
|
+
// mutation promise itself is the terminal boundary; publishing earlier
|
|
1302
|
+
// would permit a post-terminal filesystem commit.
|
|
1303
|
+
const settleBehindFence = (publish: () => void): void => {
|
|
1304
|
+
if (pendingNonAbortableExec) {
|
|
1305
|
+
void pendingNonAbortableExec.settled.then(publish);
|
|
1306
|
+
return;
|
|
1307
|
+
}
|
|
1308
|
+
publish();
|
|
1309
|
+
};
|
|
1310
|
+
const terminalize = (error: unknown, mode: "hard" | "drainable" = "hard"): void => {
|
|
1311
|
+
if (transportTerminalized) {
|
|
1312
|
+
if (callerAbortError) {
|
|
1313
|
+
terminalPendingError = callerAbortError;
|
|
1314
|
+
terminalDrainMode = false;
|
|
1315
|
+
settleBehindFence(() => settleH2(callerAbortError));
|
|
1316
|
+
}
|
|
1317
|
+
return;
|
|
1318
|
+
}
|
|
1319
|
+
transportTerminalized = true;
|
|
1320
|
+
terminalDrainMode = mode === "drainable" && !callerAbortError;
|
|
1321
|
+
if (callerAbortError) terminalPendingError = callerAbortError;
|
|
1322
|
+
else if (terminalPendingError === undefined) terminalPendingError = error;
|
|
1323
|
+
for (const close of shellGates) close();
|
|
1324
|
+
shellGates.clear();
|
|
1325
|
+
activeExecAbort?.(error instanceof Error ? error : new Error(String(error)));
|
|
1326
|
+
activeExecAbort = undefined;
|
|
1327
|
+
closeTerminalAdmission();
|
|
1328
|
+
closeTransportLocally();
|
|
1329
|
+
if (terminalDrainMode) {
|
|
1330
|
+
processPendingBuffer?.();
|
|
1331
|
+
terminalDrain?.();
|
|
1332
|
+
} else {
|
|
1333
|
+
settleBehindFence(() => settleH2(error));
|
|
1334
|
+
}
|
|
1335
|
+
};
|
|
1336
|
+
const closeForCallerAbort = () => {
|
|
1337
|
+
terminalize(callerAbortError!);
|
|
1338
|
+
};
|
|
1339
|
+
const onCallerAbort = () => {
|
|
1340
|
+
const signal = options?.signal;
|
|
1341
|
+
if (!signal) return;
|
|
1342
|
+
if (callerAbortError) return;
|
|
1343
|
+
callerAbortError = cursorAbortError(signal);
|
|
1344
|
+
closeForCallerAbort();
|
|
1345
|
+
};
|
|
1346
|
+
// Abort fence: install the listener before any setup work so a caller that
|
|
1347
|
+
// aborts during payload construction or a proxy handshake can never lose the
|
|
1348
|
+
// race against request creation and credential transmission.
|
|
1349
|
+
if (options?.signal) {
|
|
1350
|
+
// Adding an abort listener to an ALREADY-aborted signal never fires
|
|
1351
|
+
// (and the watchdog-owning provider disabled the wrapper's immediate
|
|
1352
|
+
// aborted check), so onCallerAbort is invoked directly: the cancelled
|
|
1353
|
+
// request must terminate promptly instead of opening an HTTP/2 stream
|
|
1354
|
+
// and lingering until the first-event timeout. The in-try aborted
|
|
1355
|
+
// check converts this into the stream's aborted terminal.
|
|
1356
|
+
if (options.signal.aborted) onCallerAbort();
|
|
1357
|
+
options.signal.addEventListener("abort", onCallerAbort, { once: true });
|
|
1358
|
+
}
|
|
1359
|
+
const usageState: UsageState = {
|
|
1360
|
+
sawTokenDelta: false,
|
|
1361
|
+
conversationUsedTokens: 0,
|
|
1362
|
+
checkpointOutputTokens: 0,
|
|
1363
|
+
hasConversationCheckpoint: false,
|
|
1364
|
+
};
|
|
671
1365
|
|
|
672
1366
|
try {
|
|
1367
|
+
if (options?.signal?.aborted) throw cursorAbortError(options.signal);
|
|
673
1368
|
const apiKey = options?.apiKey;
|
|
674
1369
|
if (!apiKey) {
|
|
675
1370
|
throw new Error("Cursor API key (access token) is required");
|
|
676
1371
|
}
|
|
677
|
-
if (options?.signal?.aborted)
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
1372
|
+
if (options?.signal?.aborted) throw cursorAbortError(options.signal);
|
|
1373
|
+
// Cursor owns the first-event watchdog, so its budget starts before
|
|
1374
|
+
// history/blob/protobuf serialization. Synchronous setup cannot be
|
|
1375
|
+
// interrupted by a timer; the remaining-budget check below prevents a
|
|
1376
|
+
// credential-bearing request after serialization already exhausted it.
|
|
1377
|
+
let requestByteLength = 0;
|
|
1378
|
+
const createFirstEventTimeoutError = (): FirstEventTimeoutError =>
|
|
1379
|
+
new FirstEventTimeoutError("Cursor stream timed out while waiting for the first transport event", {
|
|
1380
|
+
requestBytes: requestByteLength,
|
|
1381
|
+
firstEventElapsedMs: Date.now() - firstEventStartedAt,
|
|
1382
|
+
firstEventTimeoutMs,
|
|
1383
|
+
endpointClass,
|
|
1384
|
+
});
|
|
1385
|
+
const getRemainingFirstEventTimeoutMs = (): number | undefined =>
|
|
1386
|
+
firstEventTimeoutMs === undefined || firstEventTimeoutMs <= 0
|
|
1387
|
+
? firstEventTimeoutMs
|
|
1388
|
+
: firstEventTimeoutMs - (Date.now() - firstEventStartedAt);
|
|
1389
|
+
const assertFirstEventBudget = (): number | undefined => {
|
|
1390
|
+
const remaining = getRemainingFirstEventTimeoutMs();
|
|
1391
|
+
if (
|
|
1392
|
+
remaining !== undefined &&
|
|
1393
|
+
firstEventTimeoutMs !== undefined &&
|
|
1394
|
+
firstEventTimeoutMs > 0 &&
|
|
1395
|
+
remaining <= 0
|
|
1396
|
+
) {
|
|
1397
|
+
throw createFirstEventTimeoutError();
|
|
1398
|
+
}
|
|
1399
|
+
return remaining;
|
|
1400
|
+
};
|
|
681
1401
|
const conversationId = options?.conversationId ?? options?.sessionId ?? crypto.randomUUID();
|
|
682
|
-
|
|
683
|
-
const
|
|
684
|
-
const
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
1402
|
+
const previousCacheEntry = conversationCache.get(conversationId);
|
|
1403
|
+
const conversationContext = buildCursorConversationContext(context, model, options, baseUrl, apiKey);
|
|
1404
|
+
const reusableCacheEntry =
|
|
1405
|
+
options?.onPayload === undefined &&
|
|
1406
|
+
previousCacheEntry &&
|
|
1407
|
+
canReuseCursorConversationContext(previousCacheEntry.context, conversationContext)
|
|
1408
|
+
? previousCacheEntry
|
|
1409
|
+
: undefined;
|
|
1410
|
+
// Request construction writes history and attachment blobs. Work against a
|
|
1411
|
+
// private snapshot so aborts, hook failures, and transport failures cannot
|
|
1412
|
+
// publish partial state or leak blobs into a later request reusing the ID.
|
|
1413
|
+
const blobStore = new Map(reusableCacheEntry?.blobs);
|
|
1414
|
+
const cachedState = reusableCacheEntry?.state;
|
|
1415
|
+
usageState.conversationUsedTokens = cachedState?.tokenDetails?.usedTokens ?? 0;
|
|
1416
|
+
const setupPromise = buildGrpcRequest(model, context, options, {
|
|
693
1417
|
conversationId,
|
|
694
1418
|
blobStore,
|
|
695
|
-
conversationState:
|
|
1419
|
+
conversationState: cachedState,
|
|
696
1420
|
});
|
|
697
|
-
|
|
1421
|
+
const { requestBytes, conversationState } = await waitForCursorSetup(
|
|
1422
|
+
setupPromise,
|
|
1423
|
+
options?.signal,
|
|
1424
|
+
assertFirstEventBudget(),
|
|
1425
|
+
createFirstEventTimeoutError,
|
|
1426
|
+
);
|
|
1427
|
+
requestByteLength = requestBytes.length;
|
|
1428
|
+
let remainingFirstEventTimeoutMs = assertFirstEventBudget();
|
|
1429
|
+
// A capped non-abortable mutation remains the conversation's admission
|
|
1430
|
+
// lock until its actual handler settles. Wait before opening another
|
|
1431
|
+
// authenticated transport request.
|
|
1432
|
+
await waitForCursorMutationLock(
|
|
1433
|
+
conversationId,
|
|
1434
|
+
options?.signal,
|
|
1435
|
+
remainingFirstEventTimeoutMs,
|
|
1436
|
+
createFirstEventTimeoutError,
|
|
1437
|
+
);
|
|
1438
|
+
remainingFirstEventTimeoutMs = assertFirstEventBudget();
|
|
1439
|
+
// Recheck immediately before any network work: the caller may have
|
|
1440
|
+
// aborted while the payload was being constructed.
|
|
1441
|
+
if (options?.signal?.aborted) throw cursorAbortError(options.signal);
|
|
698
1442
|
const requestContextTools = buildMcpToolDefinitions(context.tools);
|
|
699
1443
|
const targetUrl = new URL(baseUrl);
|
|
700
1444
|
const proxyUrl = getProxyForUrl(model.provider, targetUrl);
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
1445
|
+
remainingFirstEventTimeoutMs = assertFirstEventBudget();
|
|
1446
|
+
|
|
1447
|
+
// Cursor owns the first-event watchdog, so its budget starts before
|
|
1448
|
+
// history/blob/protobuf serialization. Synchronous setup cannot be
|
|
1449
|
+
// interrupted by a timer; the remaining-budget check below prevents a
|
|
1450
|
+
// credential-bearing request after serialization already exhausted it.
|
|
1451
|
+
const idleTimeoutMs = options?.streamIdleTimeoutMs ?? getStreamIdleTimeoutMs();
|
|
1452
|
+
const clearTransportWatchdog = () => {
|
|
1453
|
+
if (transportWatchdog) {
|
|
1454
|
+
clearTimeout(transportWatchdog);
|
|
1455
|
+
transportWatchdog = null;
|
|
1456
|
+
}
|
|
1457
|
+
};
|
|
1458
|
+
const armTransportWatchdog = (timeoutMs: number | undefined, errorFactory: () => Error) => {
|
|
1459
|
+
if (transportWatchdogClosed) return;
|
|
1460
|
+
clearTransportWatchdog();
|
|
1461
|
+
if (timeoutMs === undefined || timeoutMs <= 0) return;
|
|
1462
|
+
transportWatchdog = setTimeout(() => {
|
|
1463
|
+
if (transportWatchdogClosed) return;
|
|
1464
|
+
const error = errorFactory();
|
|
1465
|
+
terminalize(error);
|
|
1466
|
+
}, timeoutMs);
|
|
1467
|
+
};
|
|
1468
|
+
const refreshTransportWatchdog = () => {
|
|
1469
|
+
if (terminalAdmissionMode !== "open") return;
|
|
1470
|
+
// An in-flight exec handler legitimately produces no inbound frames
|
|
1471
|
+
// while it runs: a heartbeat/checkpoint arriving after it started
|
|
1472
|
+
// must not re-arm the watchdog the exec path cleared, or a slow
|
|
1473
|
+
// local tool call would terminalize the stream mid-exec.
|
|
1474
|
+
if (execInFlight) return;
|
|
1475
|
+
armTransportWatchdog(idleTimeoutMs, () => new Error("stream stalled while waiting for the next event"));
|
|
1476
|
+
};
|
|
1477
|
+
armTransportWatchdog(remainingFirstEventTimeoutMs, createFirstEventTimeoutError);
|
|
704
1478
|
if (proxyUrl) {
|
|
705
|
-
|
|
1479
|
+
// The watchdog settles the h2 promise but cannot interrupt the
|
|
1480
|
+
// handshake await below: race the tunnel connect against the same
|
|
1481
|
+
// first-event deadline so setup is actually bounded, and destroy the
|
|
1482
|
+
// socket if it materializes after the deadline already fired.
|
|
1483
|
+
const tunnel = connectProxiedSocket(proxyUrl, baseUrl, {
|
|
706
1484
|
signal: options?.signal,
|
|
707
1485
|
timeoutMs: 30_000,
|
|
708
1486
|
});
|
|
1487
|
+
let tunnelDeadline: NodeJS.Timeout | undefined;
|
|
1488
|
+
const deadline = Promise.withResolvers<never>();
|
|
1489
|
+
const boundedTunnel =
|
|
1490
|
+
remainingFirstEventTimeoutMs !== undefined && remainingFirstEventTimeoutMs > 0
|
|
1491
|
+
? Promise.race([
|
|
1492
|
+
tunnel,
|
|
1493
|
+
deadline.promise.catch(error => {
|
|
1494
|
+
throw error;
|
|
1495
|
+
}),
|
|
1496
|
+
])
|
|
1497
|
+
: null;
|
|
1498
|
+
if (boundedTunnel) {
|
|
1499
|
+
// The rejection may never be observed when the tunnel wins the
|
|
1500
|
+
// race; keep it handled so it cannot surface as unhandled.
|
|
1501
|
+
deadline.promise.catch(() => {});
|
|
1502
|
+
tunnelDeadline = setTimeout(
|
|
1503
|
+
() => deadline.reject(createFirstEventTimeoutError()),
|
|
1504
|
+
remainingFirstEventTimeoutMs,
|
|
1505
|
+
);
|
|
1506
|
+
}
|
|
1507
|
+
try {
|
|
1508
|
+
proxiedSocket = await (boundedTunnel ?? tunnel);
|
|
1509
|
+
} catch (error) {
|
|
1510
|
+
// If the real tunnel still completes after the deadline won the
|
|
1511
|
+
// race, it must not leak: destroy it as soon as it lands.
|
|
1512
|
+
void tunnel.then(
|
|
1513
|
+
socket => socket.destroy(),
|
|
1514
|
+
() => {},
|
|
1515
|
+
);
|
|
1516
|
+
throw error;
|
|
1517
|
+
} finally {
|
|
1518
|
+
if (tunnelDeadline) clearTimeout(tunnelDeadline);
|
|
1519
|
+
}
|
|
1520
|
+
// The handshake may have outlived the first-event watchdog: never
|
|
1521
|
+
// create the authenticated request once the stream already failed.
|
|
1522
|
+
if (h2Settled) {
|
|
1523
|
+
proxiedSocket.destroy();
|
|
1524
|
+
await h2Completion.promise;
|
|
1525
|
+
}
|
|
1526
|
+
assertFirstEventBudget();
|
|
709
1527
|
h2Client = http2.connect(baseUrl, { createConnection: () => proxiedSocket! });
|
|
710
1528
|
} else {
|
|
711
1529
|
h2Client = http2.connect(baseUrl);
|
|
712
1530
|
}
|
|
1531
|
+
if (h2Settled) await h2Completion.promise;
|
|
1532
|
+
assertFirstEventBudget();
|
|
1533
|
+
// Recheck after the (possibly async) proxy handshake, immediately before
|
|
1534
|
+
// the bearer-authenticated request is created.
|
|
1535
|
+
if (options?.signal?.aborted) throw cursorAbortError(options.signal);
|
|
1536
|
+
h2ClientErrorHandler = error => {
|
|
1537
|
+
if (terminalBoundarySeen || terminalBoundaryObserved || sawTurnEnded) return;
|
|
1538
|
+
terminalize(error, "drainable");
|
|
1539
|
+
};
|
|
1540
|
+
h2Client.on("error", h2ClientErrorHandler);
|
|
1541
|
+
h2ClientCloseHandler = () => {
|
|
1542
|
+
if (h2Settled || localTransportCloseRequested || responseEnded || sawTurnEnded) return;
|
|
1543
|
+
terminalize(new Error("Cursor HTTP/2 session closed before turnEnded"), "drainable");
|
|
1544
|
+
};
|
|
1545
|
+
h2Client.on("close", h2ClientCloseHandler);
|
|
713
1546
|
|
|
714
1547
|
options?.onStreamCreated?.();
|
|
1548
|
+
if (options?.signal?.aborted) throw cursorAbortError(options.signal);
|
|
715
1549
|
h2Request = h2Client.request({
|
|
716
1550
|
":method": "POST",
|
|
717
1551
|
":path": "/agent.v1.AgentService/Run",
|
|
@@ -724,78 +1558,77 @@ export const streamCursor: StreamFunction<"cursor-agent"> = (
|
|
|
724
1558
|
"x-cursor-client-type": "cli",
|
|
725
1559
|
"x-request-id": crypto.randomUUID(),
|
|
726
1560
|
});
|
|
727
|
-
const
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
1561
|
+
const writer = h2Request as CursorRequestWriter;
|
|
1562
|
+
writer.isActive = () => !h2Settled && !transportTerminalized && !isClosedCursorRequest(writer);
|
|
1563
|
+
writer.registerShellGate = close => {
|
|
1564
|
+
if (!writer.isActive()) {
|
|
1565
|
+
close();
|
|
1566
|
+
return () => {};
|
|
731
1567
|
}
|
|
1568
|
+
shellGates.add(close);
|
|
1569
|
+
return () => shellGates.delete(close);
|
|
732
1570
|
};
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
let inboundSettled = false;
|
|
737
|
-
let inboundTimeout: NodeJS.Timeout | undefined;
|
|
738
|
-
const settleInbound = (error?: Error) => {
|
|
739
|
-
if (inboundSettled) return;
|
|
740
|
-
inboundSettled = true;
|
|
741
|
-
if (inboundTimeout) {
|
|
742
|
-
clearTimeout(inboundTimeout);
|
|
743
|
-
inboundTimeout = undefined;
|
|
744
|
-
}
|
|
745
|
-
if (error) inboundEnd.reject(error);
|
|
746
|
-
else inboundEnd.resolve();
|
|
1571
|
+
h2RequestErrorHandler = error => {
|
|
1572
|
+
if (terminalBoundarySeen || terminalBoundaryObserved || sawTurnEnded) return;
|
|
1573
|
+
terminalize(error, "drainable");
|
|
747
1574
|
};
|
|
748
|
-
|
|
749
|
-
const
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
1575
|
+
h2Request.on("error", h2RequestErrorHandler);
|
|
1576
|
+
const handleUnexpectedRequestClose = (kind: "closed" | "aborted"): void => {
|
|
1577
|
+
if (h2Settled || localTransportCloseRequested || responseEnded || sawTurnEnded) return;
|
|
1578
|
+
// Node emits `aborted`/`close` with rstCode=0 for a graceful remote
|
|
1579
|
+
// end while a request stream is paused. Preserve the raw-EOF path so
|
|
1580
|
+
// buffered turnEnded frames can still be parsed. A nonzero reset code
|
|
1581
|
+
// is a terminal transport failure: close admission and abort the active
|
|
1582
|
+
// exec before any queued frame can dispatch.
|
|
1583
|
+
if ((h2Request?.rstCode ?? 0) === 0) {
|
|
1584
|
+
// A graceful close can be reported before the final data/end event;
|
|
1585
|
+
// defer one turn so a coalesced turnEnded can establish success. If
|
|
1586
|
+
// no frame arrives, treat a close with no buffered work as terminal
|
|
1587
|
+
// and abort any active exec rather than waiting for the watchdog.
|
|
1588
|
+
if (gracefulCloseCheckTimer) return;
|
|
1589
|
+
gracefulCloseCheckTimer = setTimeout(() => {
|
|
1590
|
+
gracefulCloseCheckTimer = undefined;
|
|
1591
|
+
if (h2Settled || localTransportCloseRequested || responseEnded || sawTurnEnded) return;
|
|
1592
|
+
const error = new Error("Cursor stream ended before turnEnded");
|
|
1593
|
+
if (pendingBuffer.length === 0 && !processingPausedForQueue) {
|
|
1594
|
+
responseEnded = true;
|
|
1595
|
+
terminalize(error, "drainable");
|
|
1596
|
+
return;
|
|
1597
|
+
}
|
|
1598
|
+
responseEnded = true;
|
|
1599
|
+
observeBufferedTerminal(true);
|
|
1600
|
+
if (transportTerminalized) return;
|
|
1601
|
+
if (!sawTurnEnded) {
|
|
1602
|
+
terminalize(
|
|
1603
|
+
pendingBuffer.length > 0 ? new Error("Cursor HTTP/2 stream ended before turnEnded") : error,
|
|
1604
|
+
"drainable",
|
|
1605
|
+
);
|
|
1606
|
+
return;
|
|
1607
|
+
}
|
|
1608
|
+
sealExecAdmissionAtRawEof();
|
|
1609
|
+
processPendingBuffer?.();
|
|
1610
|
+
finishResponseAfterParsing();
|
|
1611
|
+
}, 0);
|
|
1612
|
+
return;
|
|
1613
|
+
}
|
|
1614
|
+
responseEnded = true;
|
|
1615
|
+
terminalize(new Error(`Cursor HTTP/2 request ${kind} before turnEnded`), "drainable");
|
|
756
1616
|
};
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
const reject = rejectH2;
|
|
767
|
-
rejectH2 = undefined;
|
|
768
|
-
reject?.(error);
|
|
769
|
-
},
|
|
770
|
-
options?.streamIdleTimeoutMs ?? getStreamIdleTimeoutMs(),
|
|
771
|
-
);
|
|
772
|
-
h2Client.on("error", error => {
|
|
773
|
-
settleInbound(error);
|
|
774
|
-
coordinator.fail(error);
|
|
775
|
-
});
|
|
776
|
-
h2Request.on("error", error => {
|
|
777
|
-
settleInbound(error);
|
|
778
|
-
coordinator.fail(error);
|
|
779
|
-
});
|
|
780
|
-
|
|
1617
|
+
h2RequestCloseHandler = () => handleUnexpectedRequestClose("closed");
|
|
1618
|
+
h2RequestAbortedHandler = () => handleUnexpectedRequestClose("aborted");
|
|
1619
|
+
h2Request.on("close", h2RequestCloseHandler);
|
|
1620
|
+
h2Request.on("aborted", h2RequestAbortedHandler);
|
|
1621
|
+
if (options?.signal?.aborted) throw cursorAbortError(options.signal);
|
|
1622
|
+
// Cursor owns the first-event watchdog, so its budget starts before
|
|
1623
|
+
// history/blob/protobuf serialization. Synchronous setup cannot be
|
|
1624
|
+
// interrupted by a timer; the remaining-budget check below prevents a
|
|
1625
|
+
// credential-bearing request after serialization already exhausted it.
|
|
781
1626
|
stream.push({ type: "start", partial: output });
|
|
782
1627
|
|
|
783
|
-
let pendingBuffer = Buffer.alloc(0);
|
|
784
|
-
const checkpointTasks: Promise<void>[] = [];
|
|
785
1628
|
let currentTextBlock: (TextContent & { index: number }) | null = null;
|
|
786
1629
|
let currentThinkingBlock: (ThinkingContent & { index: number }) | null = null;
|
|
787
1630
|
let currentToolCall: ToolCallState | null = null;
|
|
788
|
-
|
|
789
|
-
conversationState.tokenDetails && canReuseCursorUsageContext(previousUsageContext, usageContext)
|
|
790
|
-
? conversationState.tokenDetails.usedTokens
|
|
791
|
-
: 0;
|
|
792
|
-
const usageState: UsageState = {
|
|
793
|
-
sawTokenDelta: false,
|
|
794
|
-
conversationUsedTokens: cachedConversationUsedTokens,
|
|
795
|
-
checkpointOutputTokens: 0,
|
|
796
|
-
hasConversationCheckpoint: false,
|
|
797
|
-
};
|
|
798
|
-
|
|
1631
|
+
let pendingConversationCheckpoint: ConversationStateStructure | undefined;
|
|
799
1632
|
const state: BlockState = {
|
|
800
1633
|
get currentTextBlock() {
|
|
801
1634
|
return currentTextBlock;
|
|
@@ -824,133 +1657,579 @@ export const streamCursor: StreamFunction<"cursor-agent"> = (
|
|
|
824
1657
|
};
|
|
825
1658
|
|
|
826
1659
|
const onConversationCheckpoint = (checkpoint: ConversationStateStructure) => {
|
|
827
|
-
|
|
1660
|
+
pendingConversationCheckpoint = checkpoint;
|
|
828
1661
|
};
|
|
829
1662
|
|
|
1663
|
+
const messageQueue = createCursorMessageQueueForTest(error => {
|
|
1664
|
+
log("error", "handleServerMessage", { error: String(error) });
|
|
1665
|
+
terminalize(error);
|
|
1666
|
+
});
|
|
1667
|
+
terminalDrain = (): void => {
|
|
1668
|
+
if (terminalDrainStarted) return;
|
|
1669
|
+
terminalDrainStarted = true;
|
|
1670
|
+
// A transport terminal can preempt an abortable exec whose handler ignores
|
|
1671
|
+
// its signal. Do not wait on that queue chain; the bounded settlement fence
|
|
1672
|
+
// below still protects any mutation that explicitly became non-abortable.
|
|
1673
|
+
const queueCompletion =
|
|
1674
|
+
processingPausedForExec || execInFlight ? (execQueuePrefix ?? Promise.resolve()) : messageQueue.drain();
|
|
1675
|
+
void queueCompletion.then(
|
|
1676
|
+
() => {
|
|
1677
|
+
queueDrained = true;
|
|
1678
|
+
settleBehindFence(() => settleH2(terminalPendingError));
|
|
1679
|
+
},
|
|
1680
|
+
error => {
|
|
1681
|
+
queueDrained = true;
|
|
1682
|
+
settleBehindFence(() => settleH2(error));
|
|
1683
|
+
},
|
|
1684
|
+
);
|
|
1685
|
+
};
|
|
1686
|
+
const drainMessageQueue = (): void => {
|
|
1687
|
+
void messageQueue.drain().then(
|
|
1688
|
+
() => {
|
|
1689
|
+
queueDrained = true;
|
|
1690
|
+
settleH2WhenReady();
|
|
1691
|
+
},
|
|
1692
|
+
error => {
|
|
1693
|
+
queueDrained = true;
|
|
1694
|
+
settleBehindFence(() => settleH2(error));
|
|
1695
|
+
},
|
|
1696
|
+
);
|
|
1697
|
+
};
|
|
830
1698
|
h2Request.on("trailers", trailers => {
|
|
831
1699
|
const status = trailers["grpc-status"];
|
|
832
1700
|
const msg = trailers["grpc-message"];
|
|
833
1701
|
if (status && status !== "0") {
|
|
834
|
-
|
|
1702
|
+
terminalize(new Error(`gRPC error ${status}: ${decodeGrpcMessage(msg)}`), "drainable");
|
|
835
1703
|
}
|
|
836
1704
|
});
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
1705
|
+
|
|
1706
|
+
let processingPausedForExec = false;
|
|
1707
|
+
// True while any exec server message handler is running; suppresses
|
|
1708
|
+
// transport-watchdog refreshes for the duration (see refreshTransportWatchdog).
|
|
1709
|
+
let execInFlight = false;
|
|
1710
|
+
/**
|
|
1711
|
+
* Inspect buffered protocol frames while normal parsing is paused behind an
|
|
1712
|
+
* exec. This deliberately shares the Connect/protobuf framing rules with the
|
|
1713
|
+
* main parser: a complete terminal frame can preempt a held handler, while
|
|
1714
|
+
* malformed, oversized, or EOF-truncated bytes fail immediately instead of
|
|
1715
|
+
* remaining in an unbounded side buffer.
|
|
1716
|
+
*/
|
|
1717
|
+
const observeBufferedTerminal = (atEof = false): boolean => {
|
|
1718
|
+
// Once a validated terminal boundary is known, every later byte is a
|
|
1719
|
+
// tail. Never inspect its framing: a malformed or oversized tail must
|
|
1720
|
+
// not replace the already-authoritative success.
|
|
1721
|
+
if (terminalBoundarySeen || terminalBoundaryObserved) return true;
|
|
1722
|
+
let offset = bufferedObservationOffset;
|
|
1723
|
+
let observedTurnEnded = sawTurnEnded || bufferedObservationTurnEnded;
|
|
1724
|
+
while (pendingBuffer.length - offset >= 5) {
|
|
1725
|
+
const flags = pendingBuffer.byteAt(offset);
|
|
1726
|
+
const msgLen = pendingBuffer.readUInt32BE(offset + 1);
|
|
1727
|
+
if (msgLen > CURSOR_MAX_GRPC_MESSAGE_LENGTH) {
|
|
1728
|
+
const error = new Error("Cursor HTTP/2 frame exceeds the maximum message length");
|
|
1729
|
+
endStreamError = error;
|
|
1730
|
+
responseEnded = true;
|
|
1731
|
+
terminalize(error);
|
|
1732
|
+
return true;
|
|
1733
|
+
}
|
|
1734
|
+
if (pendingBuffer.length - offset < 5 + msgLen) break;
|
|
1735
|
+
const messageBytes = pendingBuffer.subarray(offset + 5, msgLen);
|
|
1736
|
+
if (flags & CONNECT_END_STREAM_FLAG) {
|
|
1737
|
+
const error = parseConnectEndStream(messageBytes);
|
|
1738
|
+
if (error) {
|
|
1739
|
+
endStreamError = error;
|
|
1740
|
+
responseEnded = true;
|
|
1741
|
+
terminalize(error, "drainable");
|
|
1742
|
+
return true;
|
|
1743
|
+
}
|
|
1744
|
+
if (!observedTurnEnded) {
|
|
1745
|
+
const missingTurnEnded = new Error("Cursor HTTP/2 stream ended before turnEnded");
|
|
1746
|
+
endStreamError = missingTurnEnded;
|
|
1747
|
+
responseEnded = true;
|
|
1748
|
+
terminalize(missingTurnEnded, "drainable");
|
|
1749
|
+
return true;
|
|
1750
|
+
}
|
|
1751
|
+
terminalBoundarySeen = true;
|
|
1752
|
+
closeTerminalAdmission();
|
|
1753
|
+
bufferedObservationOffset = offset + 5 + msgLen;
|
|
1754
|
+
bufferedObservationTurnEnded = observedTurnEnded;
|
|
1755
|
+
drainMessageQueue();
|
|
1756
|
+
return true;
|
|
1757
|
+
}
|
|
1758
|
+
try {
|
|
1759
|
+
const message = fromBinary(AgentServerMessageSchema, messageBytes);
|
|
1760
|
+
if (
|
|
1761
|
+
message.message.case === "interactionUpdate" &&
|
|
1762
|
+
message.message.value.message?.case === "turnEnded"
|
|
1763
|
+
) {
|
|
1764
|
+
observedTurnEnded = true;
|
|
1765
|
+
sawTurnEnded = true;
|
|
1766
|
+
terminalBoundaryObserved = true;
|
|
1767
|
+
bufferedTerminalBoundaryOffset = offset;
|
|
1768
|
+
closeTerminalAdmission();
|
|
1769
|
+
bufferedObservationOffset = offset + 5 + msgLen;
|
|
1770
|
+
bufferedObservationTurnEnded = true;
|
|
1771
|
+
return true;
|
|
1772
|
+
}
|
|
1773
|
+
} catch (error) {
|
|
1774
|
+
const parseError = error instanceof Error ? error : new Error(String(error));
|
|
1775
|
+
endStreamError = parseError;
|
|
1776
|
+
responseEnded = true;
|
|
1777
|
+
terminalize(parseError);
|
|
1778
|
+
return true;
|
|
1779
|
+
}
|
|
1780
|
+
offset += 5 + msgLen;
|
|
841
1781
|
}
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
1782
|
+
bufferedObservationOffset = offset;
|
|
1783
|
+
bufferedObservationTurnEnded = observedTurnEnded;
|
|
1784
|
+
if (atEof && pendingBuffer.length > offset) {
|
|
1785
|
+
const error = new Error("Cursor HTTP/2 stream ended with a truncated Connect frame");
|
|
1786
|
+
endStreamError = error;
|
|
1787
|
+
terminalize(error);
|
|
1788
|
+
return true;
|
|
1789
|
+
}
|
|
1790
|
+
return false;
|
|
851
1791
|
};
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
1792
|
+
const applyBufferedNonExecMessage = (serverMessage: AgentServerMessage): void => {
|
|
1793
|
+
log("serverMessage", serverMessage.message.case, serverMessage.message.value);
|
|
1794
|
+
switch (serverMessage.message.case) {
|
|
1795
|
+
case "interactionUpdate":
|
|
1796
|
+
processInteractionUpdate(serverMessage.message.value, output, stream, state, usageState);
|
|
1797
|
+
return;
|
|
1798
|
+
case "kvServerMessage":
|
|
1799
|
+
handleKvServerMessage(serverMessage.message.value as KvServerMessage, blobStore, writer);
|
|
1800
|
+
return;
|
|
1801
|
+
case "conversationCheckpointUpdate":
|
|
1802
|
+
handleConversationCheckpointUpdate(
|
|
1803
|
+
serverMessage.message.value,
|
|
1804
|
+
output,
|
|
1805
|
+
usageState,
|
|
1806
|
+
onConversationCheckpoint,
|
|
1807
|
+
);
|
|
1808
|
+
return;
|
|
1809
|
+
default:
|
|
1810
|
+
return;
|
|
1811
|
+
}
|
|
1812
|
+
};
|
|
1813
|
+
const finishResponseAfterParsing = (): void => {
|
|
1814
|
+
if (processingPausedForExec || processingPausedForQueue) return;
|
|
1815
|
+
if (!responseEnded) {
|
|
1816
|
+
if (terminalBoundarySeen && !hasCompleteBufferedFrame()) drainMessageQueue();
|
|
1817
|
+
return;
|
|
1818
|
+
}
|
|
1819
|
+
if (terminalBoundarySeen) {
|
|
1820
|
+
// A validated turnEnded makes every remaining byte transport tail,
|
|
1821
|
+
// including an incomplete 1–4 byte frame header. Never turn tail
|
|
1822
|
+
// noise into a truncated-stream failure after the authoritative boundary.
|
|
1823
|
+
pendingBuffer.clear();
|
|
1824
|
+
} else if (pendingBuffer.length > 0) {
|
|
1825
|
+
endStreamError = new Error("Cursor HTTP/2 stream ended with a truncated Connect frame");
|
|
1826
|
+
}
|
|
1827
|
+
drainMessageQueue();
|
|
1828
|
+
};
|
|
1829
|
+
processPendingBuffer = () => {
|
|
1830
|
+
if ((processingPausedForExec || processingPausedForQueue) && !terminalDrainMode) {
|
|
1831
|
+
observeBufferedTerminal(responseEnded);
|
|
1832
|
+
return;
|
|
1833
|
+
}
|
|
860
1834
|
while (pendingBuffer.length >= 5) {
|
|
861
|
-
|
|
1835
|
+
if (terminalBoundarySeen) {
|
|
1836
|
+
const flags = pendingBuffer.byteAt(0);
|
|
1837
|
+
const msgLen = pendingBuffer.readUInt32BE(1);
|
|
1838
|
+
if (pendingBuffer.length < 5 + msgLen) {
|
|
1839
|
+
if (responseEnded) pendingBuffer.clear();
|
|
1840
|
+
break;
|
|
1841
|
+
}
|
|
1842
|
+
const messageBytes = pendingBuffer.subarray(5, msgLen);
|
|
1843
|
+
pendingBuffer.consume(5 + msgLen);
|
|
1844
|
+
if (flags & CONNECT_END_STREAM_FLAG) {
|
|
1845
|
+
responseEnded = true;
|
|
1846
|
+
const endError = parseConnectEndStream(messageBytes);
|
|
1847
|
+
if (endError) {
|
|
1848
|
+
endStreamError = endError;
|
|
1849
|
+
terminalize(endError, "drainable");
|
|
1850
|
+
}
|
|
1851
|
+
pendingBuffer.clear();
|
|
1852
|
+
continue;
|
|
1853
|
+
}
|
|
1854
|
+
try {
|
|
1855
|
+
const serverMessage = fromBinary(AgentServerMessageSchema, messageBytes);
|
|
1856
|
+
if (serverMessage.message.case === "conversationCheckpointUpdate") {
|
|
1857
|
+
if (messageQueue.pendingBytes() + 5 + msgLen > CURSOR_MAX_QUEUED_SERVER_BYTES) {
|
|
1858
|
+
terminalize(new Error("Cursor server-message queue exceeded its bounded byte capacity"));
|
|
1859
|
+
break;
|
|
1860
|
+
}
|
|
1861
|
+
queueDrained = false;
|
|
1862
|
+
const queuedCheckpoint = messageQueue.enqueue(
|
|
1863
|
+
() => applyBufferedNonExecMessage(serverMessage),
|
|
1864
|
+
5 + msgLen,
|
|
1865
|
+
);
|
|
1866
|
+
void queuedCheckpoint.catch(() => {});
|
|
1867
|
+
drainMessageQueue();
|
|
1868
|
+
}
|
|
1869
|
+
} catch {
|
|
1870
|
+
// A validated terminal boundary makes all non-checkpoint bytes tail.
|
|
1871
|
+
}
|
|
1872
|
+
continue;
|
|
1873
|
+
}
|
|
1874
|
+
const flags = pendingBuffer.byteAt(0);
|
|
862
1875
|
const msgLen = pendingBuffer.readUInt32BE(1);
|
|
1876
|
+
if (msgLen > CURSOR_MAX_GRPC_MESSAGE_LENGTH) {
|
|
1877
|
+
terminalize(new Error("Cursor HTTP/2 frame exceeds the maximum message length"));
|
|
1878
|
+
break;
|
|
1879
|
+
}
|
|
863
1880
|
if (pendingBuffer.length < 5 + msgLen) break;
|
|
864
1881
|
|
|
865
|
-
|
|
866
|
-
|
|
1882
|
+
// Lookahead may have found turnEnded later in this same buffer while
|
|
1883
|
+
// an earlier exec was held. Track the boundary as frames are consumed;
|
|
1884
|
+
// once it is reached, normal parsing handles turnEnded and then drops
|
|
1885
|
+
// the entire tail. This keeps late execs from setting the exec pause.
|
|
1886
|
+
const atBufferedTerminalBoundary = terminalBoundaryObserved && bufferedTerminalBoundaryOffset === 0;
|
|
1887
|
+
const consumedFrameLength = 5 + msgLen;
|
|
1888
|
+
const messageBytes = pendingBuffer.subarray(5, msgLen);
|
|
1889
|
+
pendingBuffer.consume(consumedFrameLength);
|
|
1890
|
+
if (bufferedTerminalBoundaryOffset !== undefined) {
|
|
1891
|
+
bufferedTerminalBoundaryOffset = Math.max(0, bufferedTerminalBoundaryOffset - consumedFrameLength);
|
|
1892
|
+
}
|
|
1893
|
+
bufferedObservationOffset = 0;
|
|
1894
|
+
bufferedObservationTurnEnded = false;
|
|
1895
|
+
if (
|
|
1896
|
+
terminalAdmissionMode === "closed" &&
|
|
1897
|
+
!(flags & CONNECT_END_STREAM_FLAG) &&
|
|
1898
|
+
!terminalDrainMode &&
|
|
1899
|
+
!terminalBoundaryObserved
|
|
1900
|
+
)
|
|
1901
|
+
continue;
|
|
867
1902
|
|
|
868
1903
|
if (flags & CONNECT_END_STREAM_FLAG) {
|
|
869
|
-
|
|
1904
|
+
closeTerminalAdmission();
|
|
1905
|
+
responseEnded = true;
|
|
1906
|
+
terminalBoundaryObserved = false;
|
|
1907
|
+
const parsedEndError = parseConnectEndStream(messageBytes);
|
|
1908
|
+
const endError =
|
|
1909
|
+
parsedEndError ??
|
|
1910
|
+
(!sawTurnEnded ? new Error("Cursor HTTP/2 stream ended before turnEnded") : undefined);
|
|
870
1911
|
if (endError) {
|
|
871
|
-
|
|
1912
|
+
endStreamError = endError;
|
|
1913
|
+
terminalize(endError, "drainable");
|
|
872
1914
|
} else {
|
|
873
|
-
|
|
1915
|
+
terminalBoundarySeen = true;
|
|
874
1916
|
}
|
|
875
|
-
|
|
1917
|
+
pendingBuffer.clear();
|
|
1918
|
+
break;
|
|
1919
|
+
}
|
|
1920
|
+
if (messageQueue.pendingBytes() + consumedFrameLength > CURSOR_MAX_QUEUED_SERVER_BYTES) {
|
|
1921
|
+
terminalize(new Error("Cursor server-message queue exceeded its bounded byte capacity"));
|
|
1922
|
+
break;
|
|
876
1923
|
}
|
|
877
1924
|
|
|
878
1925
|
try {
|
|
879
1926
|
const serverMessage = fromBinary(AgentServerMessageSchema, messageBytes);
|
|
1927
|
+
// Cursor can make meaningful progress (heartbeats, usage deltas,
|
|
1928
|
+
// checkpoints, and server-side exec) without emitting a normalized
|
|
1929
|
+
// assistant event. Watch the validated Connect/protobuf boundary rather
|
|
1930
|
+
// than the normalized stream so those turns do not false-stall.
|
|
1931
|
+
const isMeaningful = isMeaningfulCursorServerMessage(serverMessage);
|
|
1932
|
+
if (isMeaningful) refreshTransportWatchdog();
|
|
880
1933
|
const isTurnEnded =
|
|
881
1934
|
serverMessage.message.case === "interactionUpdate" &&
|
|
882
1935
|
serverMessage.message.value.message?.case === "turnEnded";
|
|
883
1936
|
const isConversationCheckpoint = serverMessage.message.case === "conversationCheckpointUpdate";
|
|
884
|
-
if (
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
1937
|
+
if (isTurnEnded) {
|
|
1938
|
+
// Record the boundary at parse time, before the queued handler runs,
|
|
1939
|
+
// so a following coalesced END_STREAM cannot race ahead of the
|
|
1940
|
+
// already-admitted prefix and report a false missing-turn failure.
|
|
1941
|
+
sawTurnEnded = true;
|
|
1942
|
+
terminalBoundarySeen = true;
|
|
1943
|
+
terminalBoundaryObserved = false;
|
|
1944
|
+
closeTerminalAdmission(false);
|
|
1945
|
+
if (!processingPausedForExec && !processingPausedForQueue) h2Request?.resume();
|
|
1946
|
+
}
|
|
1947
|
+
if (isConversationCheckpoint && terminalBoundarySeen) {
|
|
1948
|
+
applyBufferedNonExecMessage(serverMessage);
|
|
1949
|
+
continue;
|
|
1950
|
+
}
|
|
1951
|
+
if (atBufferedTerminalBoundary && !isTurnEnded) continue;
|
|
1952
|
+
// Serialize handlers: exec messages can be asynchronous, and resolving the
|
|
1953
|
+
// request on turnEnded before prior handlers finish loses their responses.
|
|
1954
|
+
const isExecServerMessage = serverMessage.message.case === "execServerMessage";
|
|
1955
|
+
if (terminalAdmissionMode === "raw-eof" && isExecServerMessage && !sawTurnEnded) continue;
|
|
1956
|
+
if (terminalDrainMode) {
|
|
1957
|
+
if (terminalBoundarySeen || isExecServerMessage) continue;
|
|
1958
|
+
if (isTurnEnded) {
|
|
1959
|
+
sawTurnEnded = true;
|
|
1960
|
+
terminalBoundarySeen = true;
|
|
1961
|
+
closeTerminalAdmission();
|
|
1962
|
+
continue;
|
|
1963
|
+
}
|
|
1964
|
+
applyBufferedNonExecMessage(serverMessage);
|
|
1965
|
+
continue;
|
|
1966
|
+
}
|
|
1967
|
+
const isExecutable = isExecServerMessage && isMeaningful;
|
|
1968
|
+
if (isExecutable) {
|
|
1969
|
+
processingPausedForExec = true;
|
|
1970
|
+
h2Request!.pause();
|
|
1971
|
+
clearTransportWatchdog();
|
|
1972
|
+
execQueuePrefix = messageQueue.drain();
|
|
1973
|
+
}
|
|
1974
|
+
let mutationSlotReserved = false;
|
|
1975
|
+
const queued = messageQueue.enqueue(async () => {
|
|
1976
|
+
const dropExecutable = (): void => {
|
|
1977
|
+
if (!isExecutable) return;
|
|
1978
|
+
processingPausedForExec = false;
|
|
1979
|
+
processPendingBuffer?.();
|
|
1980
|
+
};
|
|
1981
|
+
if (transportTerminalized && !(terminalDrainMode && !isExecServerMessage)) {
|
|
1982
|
+
dropExecutable();
|
|
1983
|
+
return;
|
|
1984
|
+
}
|
|
1985
|
+
if (
|
|
1986
|
+
isExecServerMessage &&
|
|
1987
|
+
terminalAdmissionMode === "closed" &&
|
|
1988
|
+
!(terminalBoundaryObserved && !terminalBoundarySeen)
|
|
1989
|
+
) {
|
|
1990
|
+
dropExecutable();
|
|
1991
|
+
return;
|
|
1992
|
+
}
|
|
1993
|
+
// An exec frame asks this process to perform work before Cursor can
|
|
1994
|
+
// response. Its deadline is independent from raw transport
|
|
1995
|
+
// progress, and pausing the request supplies bounded backpressure.
|
|
1996
|
+
if (isExecutable) {
|
|
1997
|
+
clearTransportWatchdog();
|
|
1998
|
+
execInFlight = true;
|
|
1999
|
+
}
|
|
2000
|
+
let execSucceeded = false;
|
|
2001
|
+
try {
|
|
2002
|
+
const run = (execSignal?: AbortSignal, markNonAbortable?: () => void) =>
|
|
2003
|
+
handleServerMessage(
|
|
2004
|
+
serverMessage,
|
|
889
2005
|
output,
|
|
2006
|
+
stream,
|
|
2007
|
+
state,
|
|
2008
|
+
blobStore,
|
|
2009
|
+
writer,
|
|
2010
|
+
options?.execHandlers,
|
|
2011
|
+
options?.onToolResult,
|
|
890
2012
|
usageState,
|
|
2013
|
+
requestContextTools,
|
|
891
2014
|
onConversationCheckpoint,
|
|
2015
|
+
requestContextRules,
|
|
2016
|
+
execSignal,
|
|
2017
|
+
markNonAbortable,
|
|
892
2018
|
);
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
2019
|
+
if (isExecutable) {
|
|
2020
|
+
// The deadline races the handler promise but the per-exec
|
|
2021
|
+
// AbortSignal it owns reaches cooperative handlers so caller
|
|
2022
|
+
// cancellation and the local deadline can actually stop work.
|
|
2023
|
+
await runWithCursorExecDeadline(
|
|
2024
|
+
run,
|
|
2025
|
+
options?.signal,
|
|
2026
|
+
cursorExecDeadlineMsForTest(idleTimeoutMs),
|
|
2027
|
+
settlement => {
|
|
2028
|
+
if (!reserveCursorMutationLock(conversationId)) {
|
|
2029
|
+
throw new CursorExecAdmissionClosedError(
|
|
2030
|
+
"Cursor non-abortable mutation capacity exhausted",
|
|
2031
|
+
);
|
|
2032
|
+
}
|
|
2033
|
+
mutationSlotReserved = true;
|
|
2034
|
+
pendingNonAbortableExec = settlement;
|
|
2035
|
+
const lock = settlement.settled.then(
|
|
2036
|
+
() => undefined,
|
|
2037
|
+
() => undefined,
|
|
2038
|
+
);
|
|
2039
|
+
registerCursorMutationLock(conversationId, lock);
|
|
2040
|
+
releaseCursorMutationLockReservation(conversationId);
|
|
2041
|
+
mutationSlotReserved = false;
|
|
2042
|
+
},
|
|
2043
|
+
() => {
|
|
2044
|
+
if (mutationSlotReserved) {
|
|
2045
|
+
releaseCursorMutationLockReservation(conversationId);
|
|
2046
|
+
mutationSlotReserved = false;
|
|
2047
|
+
}
|
|
2048
|
+
},
|
|
2049
|
+
() => {
|
|
2050
|
+
activeExecAbort = undefined;
|
|
2051
|
+
if (mutationSlotReserved) {
|
|
2052
|
+
releaseCursorMutationLockReservation(conversationId);
|
|
2053
|
+
mutationSlotReserved = false;
|
|
2054
|
+
}
|
|
2055
|
+
},
|
|
2056
|
+
abort => {
|
|
2057
|
+
activeExecAbort = abort;
|
|
2058
|
+
},
|
|
2059
|
+
() => transportTerminalized,
|
|
2060
|
+
);
|
|
2061
|
+
} else {
|
|
2062
|
+
await run();
|
|
2063
|
+
}
|
|
2064
|
+
execSucceeded = true;
|
|
2065
|
+
} finally {
|
|
2066
|
+
if (isExecutable) {
|
|
2067
|
+
execInFlight = false;
|
|
2068
|
+
if (
|
|
2069
|
+
execSucceeded &&
|
|
2070
|
+
!transportWatchdogClosed &&
|
|
2071
|
+
!callerAbortError &&
|
|
2072
|
+
terminalAdmissionMode !== "closed"
|
|
2073
|
+
) {
|
|
2074
|
+
processingPausedForExec = false;
|
|
2075
|
+
h2Request!.resume();
|
|
2076
|
+
if (isMeaningful) refreshTransportWatchdog();
|
|
2077
|
+
processPendingBuffer?.();
|
|
2078
|
+
} else if (transportTerminalized || terminalAdmissionMode === "closed") {
|
|
2079
|
+
// A queued exec may be dropped after a trailer/reset or another
|
|
2080
|
+
// earlier terminal closes admission. Do not leave parser state
|
|
2081
|
+
// permanently paused while that dropped promise accounts down.
|
|
2082
|
+
processingPausedForExec = false;
|
|
2083
|
+
if (terminalBoundaryObserved || terminalBoundarySeen) h2Request!.resume();
|
|
2084
|
+
processPendingBuffer?.();
|
|
2085
|
+
}
|
|
2086
|
+
}
|
|
2087
|
+
}
|
|
2088
|
+
}, consumedFrameLength);
|
|
2089
|
+
void queued.catch(() => {});
|
|
2090
|
+
// Terminal bookkeeping belongs to the validated frame boundary,
|
|
2091
|
+
// before parser backpressure can break this loop. The queued handler
|
|
2092
|
+
// still runs in order, while settlement waits for queue drain.
|
|
2093
|
+
if (isTurnEnded) {
|
|
2094
|
+
sawTurnEnded = true;
|
|
2095
|
+
// Make the boundary durable before inspecting the next frame's
|
|
2096
|
+
// header. A malformed or oversized coalesced tail is not allowed
|
|
2097
|
+
// to replace this validated terminal success.
|
|
2098
|
+
terminalBoundarySeen = true;
|
|
2099
|
+
terminalBoundaryObserved = false;
|
|
2100
|
+
closeTerminalAdmission(false);
|
|
2101
|
+
drainMessageQueue();
|
|
2102
|
+
}
|
|
2103
|
+
// A single HTTP/2 data chunk can contain hundreds of valid,
|
|
2104
|
+
// inexpensive Connect frames. Stop parsing at the queue bound and
|
|
2105
|
+
// resume after the ordered chain drains instead of rejecting the
|
|
2106
|
+
// 257th frame before any queued microtask can decrement pending.
|
|
2107
|
+
if (!isExecServerMessage && messageQueue.pending() >= CURSOR_MAX_PENDING_SERVER_MESSAGES) {
|
|
2108
|
+
processingPausedForQueue = true;
|
|
2109
|
+
if (!terminalBoundaryObserved && !terminalBoundarySeen) h2Request!.pause();
|
|
2110
|
+
const resumeAfterDrain = () => {
|
|
2111
|
+
processingPausedForQueue = false;
|
|
2112
|
+
if (processingPausedForExec || callerAbortError) return;
|
|
2113
|
+
if (transportWatchdogClosed && !terminalBoundaryObserved && !terminalBoundarySeen) return;
|
|
2114
|
+
if (terminalAdmissionMode !== "closed" || terminalBoundaryObserved || terminalBoundarySeen)
|
|
2115
|
+
h2Request!.resume();
|
|
2116
|
+
// A lookahead turnEnded closes admission before the validated
|
|
2117
|
+
// prefix reaches the queue bound. Continue parsing that prefix
|
|
2118
|
+
// without reopening transport or admitting tail execs.
|
|
2119
|
+
if (
|
|
2120
|
+
!transportTerminalized ||
|
|
2121
|
+
terminalDrainMode ||
|
|
2122
|
+
terminalBoundaryObserved ||
|
|
2123
|
+
terminalBoundarySeen
|
|
2124
|
+
)
|
|
2125
|
+
processPendingBuffer?.();
|
|
2126
|
+
};
|
|
2127
|
+
// Consume both outcomes: `finally()` would create a second rejected
|
|
2128
|
+
// promise when the boundary handler fails, even though the queue's
|
|
2129
|
+
// normal error path already consumed the original rejection.
|
|
2130
|
+
void messageQueue.drain().then(resumeAfterDrain, resumeAfterDrain);
|
|
2131
|
+
break;
|
|
897
2132
|
}
|
|
898
|
-
// Serialize handlers: exec messages can be asynchronous, and resolving the
|
|
899
|
-
// request on turnEnded before prior handlers finish loses their responses.
|
|
900
|
-
if (!coordinator.canAdmitTask()) continue;
|
|
901
|
-
coordinator.admit(() =>
|
|
902
|
-
handleServerMessage(
|
|
903
|
-
serverMessage,
|
|
904
|
-
output,
|
|
905
|
-
stream,
|
|
906
|
-
state,
|
|
907
|
-
blobStore,
|
|
908
|
-
coordinator,
|
|
909
|
-
options?.execHandlers,
|
|
910
|
-
options?.onToolResult,
|
|
911
|
-
usageState,
|
|
912
|
-
requestContextTools,
|
|
913
|
-
onConversationCheckpoint,
|
|
914
|
-
requestContextRules,
|
|
915
|
-
),
|
|
916
|
-
);
|
|
917
2133
|
|
|
918
|
-
if (
|
|
919
|
-
|
|
2134
|
+
if (isExecutable) {
|
|
2135
|
+
observeBufferedTerminal(responseEnded);
|
|
2136
|
+
break;
|
|
920
2137
|
}
|
|
921
2138
|
} catch (e) {
|
|
922
2139
|
log("error", "parseServerMessage", { error: String(e) });
|
|
2140
|
+
terminalize(e);
|
|
2141
|
+
break;
|
|
923
2142
|
}
|
|
924
2143
|
}
|
|
2144
|
+
// HTTP/2 can emit `end` while a coalesced chunk still has frames
|
|
2145
|
+
// parked behind queue backpressure. Drain only after every buffered
|
|
2146
|
+
// frame has been parsed into the ordered queue.
|
|
2147
|
+
finishResponseAfterParsing();
|
|
2148
|
+
};
|
|
2149
|
+
|
|
2150
|
+
h2Request.on("end", () => {
|
|
2151
|
+
responseEnded = true;
|
|
2152
|
+
if (endStreamError && !h2Settled) {
|
|
2153
|
+
terminalize(endStreamError);
|
|
2154
|
+
return;
|
|
2155
|
+
}
|
|
2156
|
+
if (observeBufferedTerminal(true)) {
|
|
2157
|
+
if (terminalDrainMode) {
|
|
2158
|
+
processPendingBuffer?.();
|
|
2159
|
+
terminalDrain?.();
|
|
2160
|
+
}
|
|
2161
|
+
return;
|
|
2162
|
+
}
|
|
2163
|
+
if (transportTerminalized) return;
|
|
2164
|
+
if (!sawTurnEnded && !h2Settled) {
|
|
2165
|
+
terminalize(
|
|
2166
|
+
pendingBuffer.length > 0
|
|
2167
|
+
? new Error("Cursor HTTP/2 stream ended before turnEnded")
|
|
2168
|
+
: new Error("Cursor stream ended before turnEnded"),
|
|
2169
|
+
"drainable",
|
|
2170
|
+
);
|
|
2171
|
+
return;
|
|
2172
|
+
}
|
|
2173
|
+
sealExecAdmissionAtRawEof();
|
|
2174
|
+
processPendingBuffer?.();
|
|
2175
|
+
finishResponseAfterParsing();
|
|
925
2176
|
});
|
|
926
2177
|
|
|
927
|
-
|
|
2178
|
+
h2Request.on("data", (chunk: Buffer) => {
|
|
2179
|
+
if (terminalBoundarySeen) {
|
|
2180
|
+
const remaining = CURSOR_MAX_PENDING_SERVER_BYTES - pendingBuffer.length;
|
|
2181
|
+
if (remaining > 0) pendingBuffer.append(chunk.subarray(0, remaining));
|
|
2182
|
+
refreshPostTurnEndedGrace();
|
|
2183
|
+
processPendingBuffer?.();
|
|
2184
|
+
return;
|
|
2185
|
+
}
|
|
2186
|
+
let offset = 0;
|
|
2187
|
+
while (
|
|
2188
|
+
offset < chunk.length &&
|
|
2189
|
+
!h2Settled &&
|
|
2190
|
+
!transportTerminalized &&
|
|
2191
|
+
!terminalBoundarySeen &&
|
|
2192
|
+
!terminalBoundaryObserved
|
|
2193
|
+
) {
|
|
2194
|
+
const available = CURSOR_MAX_PENDING_SERVER_BYTES - pendingBuffer.length;
|
|
2195
|
+
if (available <= 0) {
|
|
2196
|
+
processPendingBuffer?.();
|
|
2197
|
+
if (h2Settled) return;
|
|
2198
|
+
const error = new Error("Cursor HTTP/2 response exceeded the maximum pending byte length");
|
|
2199
|
+
endStreamError = error;
|
|
2200
|
+
terminalize(error);
|
|
2201
|
+
return;
|
|
2202
|
+
}
|
|
2203
|
+
const length = Math.min(available, chunk.length - offset);
|
|
2204
|
+
pendingBuffer.append(chunk.subarray(offset, offset + length));
|
|
2205
|
+
offset += length;
|
|
2206
|
+
processPendingBuffer?.();
|
|
2207
|
+
}
|
|
2208
|
+
});
|
|
2209
|
+
|
|
2210
|
+
if (callerAbortError) throw callerAbortError;
|
|
2211
|
+
if (h2Settled) {
|
|
2212
|
+
await h2Completion.promise;
|
|
2213
|
+
}
|
|
2214
|
+
writeCursorFrame(writer, frameConnectMessage(requestBytes));
|
|
928
2215
|
|
|
929
2216
|
const sendHeartbeat = () => {
|
|
930
|
-
if (
|
|
2217
|
+
if (h2Settled || isClosedCursorRequest(writer)) return;
|
|
931
2218
|
const heartbeatMessage = create(AgentClientMessageSchema, {
|
|
932
2219
|
message: { case: "clientHeartbeat", value: create(ClientHeartbeatSchema, {}) },
|
|
933
2220
|
});
|
|
934
2221
|
const heartbeatBytes = toBinary(AgentClientMessageSchema, heartbeatMessage);
|
|
935
|
-
|
|
2222
|
+
writeCursorFrame(writer, frameConnectMessage(heartbeatBytes));
|
|
936
2223
|
};
|
|
937
2224
|
|
|
938
2225
|
heartbeatTimer = setInterval(sendHeartbeat, 5000);
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
} else if (coordinator.hasTurnEnded() && !coordinator.isActive()) {
|
|
947
|
-
resolveH2 = undefined;
|
|
948
|
-
resolve();
|
|
949
|
-
}
|
|
950
|
-
});
|
|
951
|
-
armInboundTimeout();
|
|
952
|
-
await inboundEnd.promise;
|
|
953
|
-
await Promise.all(checkpointTasks);
|
|
2226
|
+
// The watchdog was armed before setup; never restart it after request creation.
|
|
2227
|
+
await h2Completion.promise;
|
|
2228
|
+
// A successful terminal frame can settle before the HTTP/2 writer callback.
|
|
2229
|
+
// Bound this final drain so a peer that stopped reading cannot hold the
|
|
2230
|
+
// request open forever, and surface asynchronous callback failures as a
|
|
2231
|
+
// failed request instead of publishing a false successful result.
|
|
2232
|
+
await waitForCursorWrites(h2Request, CURSOR_WRITE_DRAIN_TIMEOUT_MS, forceCloseTransport);
|
|
954
2233
|
|
|
955
2234
|
if (state.currentTextBlock) {
|
|
956
2235
|
const idx = output.content.indexOf(state.currentTextBlock);
|
|
@@ -985,39 +2264,33 @@ export const streamCursor: StreamFunction<"cursor-agent"> = (
|
|
|
985
2264
|
}
|
|
986
2265
|
|
|
987
2266
|
finalizeCursorUsage(output, usageState);
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
);
|
|
2267
|
+
if (options?.onPayload === undefined && conversationCache.get(conversationId) === previousCacheEntry) {
|
|
2268
|
+
const checkpointState = pendingConversationCheckpoint ?? conversationState;
|
|
2269
|
+
const stateToCommit =
|
|
2270
|
+
usageState.hasConversationCheckpoint || usageState.conversationUsedTokens > 0
|
|
2271
|
+
? create(ConversationStateStructureSchema, {
|
|
2272
|
+
...checkpointState,
|
|
2273
|
+
tokenDetails: create(ConversationTokenDetailsSchema, {
|
|
2274
|
+
usedTokens: output.usage.totalTokens,
|
|
2275
|
+
maxTokens: checkpointState.tokenDetails?.maxTokens ?? 0,
|
|
2276
|
+
}),
|
|
2277
|
+
})
|
|
2278
|
+
: checkpointState;
|
|
2279
|
+
conversationCache.set(conversationId, {
|
|
2280
|
+
state: stateToCommit,
|
|
2281
|
+
blobs: blobStore,
|
|
2282
|
+
context: {
|
|
2283
|
+
...conversationContext,
|
|
2284
|
+
messageKeys: [...conversationContext.messageKeys, hashCursorConversationMessage(output)],
|
|
2285
|
+
},
|
|
2286
|
+
});
|
|
1009
2287
|
touchCursorConversation(conversationId);
|
|
1010
2288
|
}
|
|
1011
|
-
conversationUsageContextCache.set(conversationId, {
|
|
1012
|
-
...usageContext,
|
|
1013
|
-
messageKeys: [...usageContext.messageKeys, hashCursorUsageMessage(output)],
|
|
1014
|
-
});
|
|
1015
|
-
conversationBlobStores.set(conversationId, blobStore);
|
|
1016
|
-
touchCursorConversation(conversationId);
|
|
1017
2289
|
calculateCost(model, output.usage);
|
|
1018
2290
|
|
|
1019
2291
|
output.duration = Date.now() - startTime;
|
|
1020
2292
|
if (firstTokenTime) output.ttft = firstTokenTime - startTime;
|
|
2293
|
+
completedSuccessfully = true;
|
|
1021
2294
|
stream.push({
|
|
1022
2295
|
type: "done",
|
|
1023
2296
|
reason: output.stopReason as "stop" | "length" | "toolUse",
|
|
@@ -1025,34 +2298,81 @@ export const streamCursor: StreamFunction<"cursor-agent"> = (
|
|
|
1025
2298
|
});
|
|
1026
2299
|
stream.end();
|
|
1027
2300
|
} catch (error) {
|
|
1028
|
-
if (activeConversationId) {
|
|
1029
|
-
if (previousConversationState) conversationStateCache.set(activeConversationId, previousConversationState);
|
|
1030
|
-
else conversationStateCache.delete(activeConversationId);
|
|
1031
|
-
if (previousUsageContext) conversationUsageContextCache.set(activeConversationId, previousUsageContext);
|
|
1032
|
-
else conversationUsageContextCache.delete(activeConversationId);
|
|
1033
|
-
}
|
|
1034
2301
|
// Keep the completion promise terminal even for synchronous setup/write
|
|
1035
2302
|
// failures that may not emit a separate HTTP/2 error event.
|
|
1036
|
-
|
|
1037
|
-
|
|
2303
|
+
if (!h2Settled) terminalize(error);
|
|
2304
|
+
// Caller cancellation remains authoritative even when a transport event
|
|
2305
|
+
// rejected h2Completion first; the abort listener can run while the
|
|
2306
|
+
// settlement fence is awaiting a detached mutation.
|
|
2307
|
+
const mappedError = callerAbortError ?? h2Failure ?? error;
|
|
2308
|
+
output.stopReason = callerAbortError || options?.signal?.aborted ? "aborted" : "error";
|
|
1038
2309
|
output.errorStatus = extractHttpStatusFromError(mappedError);
|
|
2310
|
+
output.transportFailure = transportFailureFacts(mappedError);
|
|
1039
2311
|
output.errorMessage = formatErrorMessageWithRetryAfter(mappedError);
|
|
2312
|
+
finalizeCursorUsage(output, usageState);
|
|
2313
|
+
calculateCost(model, output.usage);
|
|
1040
2314
|
output.duration = Date.now() - startTime;
|
|
1041
2315
|
if (firstTokenTime) output.ttft = firstTokenTime - startTime;
|
|
1042
2316
|
stream.push({ type: "error", reason: output.stopReason, error: output });
|
|
1043
2317
|
stream.end();
|
|
1044
2318
|
} finally {
|
|
2319
|
+
options?.signal?.removeEventListener("abort", onCallerAbort);
|
|
2320
|
+
if (gracefulCloseCheckTimer) {
|
|
2321
|
+
clearTimeout(gracefulCloseCheckTimer);
|
|
2322
|
+
gracefulCloseCheckTimer = undefined;
|
|
2323
|
+
}
|
|
2324
|
+
pendingBuffer.clear();
|
|
2325
|
+
bufferedObservationOffset = 0;
|
|
2326
|
+
bufferedObservationTurnEnded = false;
|
|
2327
|
+
bufferedTerminalBoundaryOffset = undefined;
|
|
2328
|
+
terminalBoundaryObserved = false;
|
|
2329
|
+
transportWatchdogClosed = true;
|
|
2330
|
+
if (transportWatchdog) {
|
|
2331
|
+
clearTimeout(transportWatchdog);
|
|
2332
|
+
transportWatchdog = null;
|
|
2333
|
+
}
|
|
1045
2334
|
if (heartbeatTimer) {
|
|
1046
2335
|
clearInterval(heartbeatTimer);
|
|
1047
2336
|
heartbeatTimer = null;
|
|
1048
2337
|
}
|
|
1049
|
-
if (
|
|
1050
|
-
|
|
2338
|
+
if (postTurnEndedCheckpointTimer) {
|
|
2339
|
+
clearTimeout(postTurnEndedCheckpointTimer);
|
|
2340
|
+
postTurnEndedCheckpointTimer = undefined;
|
|
1051
2341
|
}
|
|
1052
|
-
if (h2Request &&
|
|
1053
|
-
h2Request.
|
|
2342
|
+
if (h2Request && h2RequestErrorHandler) {
|
|
2343
|
+
h2Request.removeListener("error", h2RequestErrorHandler);
|
|
2344
|
+
}
|
|
2345
|
+
if (h2Client && h2ClientErrorHandler) {
|
|
2346
|
+
// Keep a listener installed while the session closes. Node treats a late
|
|
2347
|
+
// ClientHttp2Session error without listeners as an uncaught exception.
|
|
2348
|
+
h2Client.on("error", () => {});
|
|
2349
|
+
h2Client.removeListener("error", h2ClientErrorHandler);
|
|
2350
|
+
}
|
|
2351
|
+
// A queued exec handler can still be draining when the caller aborts; its
|
|
2352
|
+
// late writes must fail quietly on the closed stream instead of crashing
|
|
2353
|
+
// the process with ERR_STREAM_WRITE_AFTER_END.
|
|
2354
|
+
h2Request?.on("error", () => {});
|
|
2355
|
+
// `write()` only queues the frame. Await each accepted frame's completion
|
|
2356
|
+
// callback before tearing down a successful HTTP/2 request, otherwise the
|
|
2357
|
+
// final exec response can be lost when close wins the writer race.
|
|
2358
|
+
await waitForCursorWrites(h2Request, CURSOR_WRITE_DRAIN_TIMEOUT_MS, forceCloseTransport).catch(() => {});
|
|
2359
|
+
if (completedSuccessfully) {
|
|
2360
|
+
const requestEnded = h2Request
|
|
2361
|
+
? isClosedCursorRequest(h2Request) || (await endCursorRequestForTest(h2Request))
|
|
2362
|
+
: true;
|
|
2363
|
+
if (!requestEnded) forceCloseTransport();
|
|
2364
|
+
h2Client?.close();
|
|
2365
|
+
// A valid turnEnded can arrive before the peer closes its response half.
|
|
2366
|
+
// Send END_STREAM first; only force cleanup after a bounded grace period
|
|
2367
|
+
// when the peer leaves the completed stream open indefinitely.
|
|
2368
|
+
if (requestEnded && h2Request && !h2Request.closed && !h2Request.destroyed) {
|
|
2369
|
+
const gracefulTeardownTimer = setTimeout(forceCloseTransport, 100);
|
|
2370
|
+
h2Request.once("close", () => clearTimeout(gracefulTeardownTimer));
|
|
2371
|
+
}
|
|
2372
|
+
} else {
|
|
2373
|
+
h2Request?.close();
|
|
2374
|
+
h2Client?.close();
|
|
1054
2375
|
}
|
|
1055
|
-
h2Client?.close();
|
|
1056
2376
|
proxiedSocket?.destroy();
|
|
1057
2377
|
}
|
|
1058
2378
|
})();
|
|
@@ -1064,7 +2384,7 @@ type ToolCallState = ToolCall & {
|
|
|
1064
2384
|
index: number;
|
|
1065
2385
|
partialJson?: string;
|
|
1066
2386
|
kind: "mcp" | "todo_write" | "native" | "cursor-exec";
|
|
1067
|
-
[
|
|
2387
|
+
[kProviderResolvedToolCall]?: true;
|
|
1068
2388
|
};
|
|
1069
2389
|
|
|
1070
2390
|
interface BlockState {
|
|
@@ -1080,24 +2400,9 @@ interface BlockState {
|
|
|
1080
2400
|
|
|
1081
2401
|
interface UsageState {
|
|
1082
2402
|
sawTokenDelta: boolean;
|
|
1083
|
-
/**
|
|
1084
|
-
* Latest `ConversationTokenDetails.used_tokens`: the whole conversation's
|
|
1085
|
-
* token consumption as counted by Cursor, not this turn's output.
|
|
1086
|
-
*/
|
|
1087
2403
|
conversationUsedTokens: number;
|
|
1088
|
-
/** Output tokens already included in the latest checkpoint snapshot. */
|
|
1089
2404
|
checkpointOutputTokens: number;
|
|
1090
|
-
/** Whether the current stream received a checkpoint, including an explicit zero. */
|
|
1091
2405
|
hasConversationCheckpoint: boolean;
|
|
1092
|
-
pendingCheckpoint?: ConversationStateStructure;
|
|
1093
|
-
}
|
|
1094
|
-
|
|
1095
|
-
interface CursorUsageContext {
|
|
1096
|
-
modelKey: string;
|
|
1097
|
-
systemPromptKey: string;
|
|
1098
|
-
customSystemPromptKey: string;
|
|
1099
|
-
toolsKey: string;
|
|
1100
|
-
messageKeys: string[];
|
|
1101
2406
|
}
|
|
1102
2407
|
|
|
1103
2408
|
async function handleServerMessage(
|
|
@@ -1113,6 +2418,8 @@ async function handleServerMessage(
|
|
|
1113
2418
|
requestContextTools: McpToolDefinition[],
|
|
1114
2419
|
onConversationCheckpoint?: (checkpoint: ConversationStateStructure) => void,
|
|
1115
2420
|
requestContextRules: CursorRule[] = [],
|
|
2421
|
+
execSignal?: AbortSignal,
|
|
2422
|
+
markNonAbortable?: () => void,
|
|
1116
2423
|
): Promise<void> {
|
|
1117
2424
|
const msgCase = msg.message.case;
|
|
1118
2425
|
|
|
@@ -1132,6 +2439,8 @@ async function handleServerMessage(
|
|
|
1132
2439
|
output,
|
|
1133
2440
|
stream,
|
|
1134
2441
|
requestContextRules,
|
|
2442
|
+
execSignal,
|
|
2443
|
+
markNonAbortable,
|
|
1135
2444
|
);
|
|
1136
2445
|
} else if (msgCase === "conversationCheckpointUpdate") {
|
|
1137
2446
|
handleConversationCheckpointUpdate(msg.message.value, output, usageState, onConversationCheckpoint);
|
|
@@ -1164,19 +2473,21 @@ function handleKvServerMessage(
|
|
|
1164
2473
|
});
|
|
1165
2474
|
|
|
1166
2475
|
const responseBytes = toBinary(AgentClientMessageSchema, kvClientMessage);
|
|
1167
|
-
writer
|
|
2476
|
+
writeCursorFrame(writer, frameConnectMessage(responseBytes));
|
|
1168
2477
|
|
|
1169
2478
|
log("kvClient", "getBlobResult", { blobId: blobIdKey.slice(0, 40) });
|
|
1170
2479
|
} else if (kvCase === "setBlobArgs") {
|
|
1171
2480
|
const { blobId, blobData } = kvMsg.message.value;
|
|
1172
2481
|
const blobIdKey = Buffer.from(blobId).toString("hex");
|
|
1173
|
-
|
|
2482
|
+
const stored = blobId.byteLength === CURSOR_BLOB_ID_BYTES && putCursorBlob(blobStore, blobIdKey, blobData);
|
|
1174
2483
|
|
|
1175
2484
|
const response = create(KvClientMessageSchema, {
|
|
1176
2485
|
id: kvMsg.id,
|
|
1177
2486
|
message: {
|
|
1178
2487
|
case: "setBlobResult",
|
|
1179
|
-
value: create(SetBlobResultSchema, {
|
|
2488
|
+
value: create(SetBlobResultSchema, {
|
|
2489
|
+
error: stored ? undefined : { message: "Cursor blob store exceeded its bounded capacity" },
|
|
2490
|
+
}),
|
|
1180
2491
|
},
|
|
1181
2492
|
});
|
|
1182
2493
|
|
|
@@ -1185,12 +2496,61 @@ function handleKvServerMessage(
|
|
|
1185
2496
|
});
|
|
1186
2497
|
|
|
1187
2498
|
const responseBytes = toBinary(AgentClientMessageSchema, kvClientMessage);
|
|
1188
|
-
writer
|
|
2499
|
+
writeCursorFrame(writer, frameConnectMessage(responseBytes));
|
|
1189
2500
|
|
|
1190
2501
|
log("kvClient", "setBlobResult", { blobId: blobIdKey.slice(0, 40) });
|
|
1191
2502
|
}
|
|
1192
2503
|
}
|
|
1193
2504
|
|
|
2505
|
+
/**
|
|
2506
|
+
* Insert a blob into the conversation store under a byte budget, shedding the
|
|
2507
|
+
* least recently written entries when the budget is exceeded.
|
|
2508
|
+
*
|
|
2509
|
+
* Refusing the write is not an option a conversation can recover from. The
|
|
2510
|
+
* store is carried across turns, so once it is full every later `setBlob`
|
|
2511
|
+
* fails, every tool result that depends on one fails with it, and the session
|
|
2512
|
+
* is dead for the rest of its life — compaction and process restart both
|
|
2513
|
+
* rebuild the same oversized store. A dropped historical blob is an already
|
|
2514
|
+
* modelled `getBlob` miss; a refused write is terminal. Shed instead.
|
|
2515
|
+
*
|
|
2516
|
+
* Only two writes are refused: a blob larger than the entire budget, which can
|
|
2517
|
+
* never be retained, and an invalid identifier (rejected by the caller).
|
|
2518
|
+
*/
|
|
2519
|
+
function putCursorBlob(
|
|
2520
|
+
blobStore: Map<string, Uint8Array>,
|
|
2521
|
+
blobId: string,
|
|
2522
|
+
blobData: Uint8Array,
|
|
2523
|
+
limits: { maxBytes: number } = { maxBytes: CURSOR_MAX_BLOB_STORE_BYTES },
|
|
2524
|
+
): boolean {
|
|
2525
|
+
if (blobData.byteLength > limits.maxBytes) return false;
|
|
2526
|
+
// Re-insert so an overwritten or re-stored blob counts as the newest entry:
|
|
2527
|
+
// Map iteration order is insertion order, which is what eviction walks.
|
|
2528
|
+
blobStore.delete(blobId);
|
|
2529
|
+
blobStore.set(blobId, blobData);
|
|
2530
|
+
let totalBytes = 0;
|
|
2531
|
+
for (const value of blobStore.values()) totalBytes += value.byteLength;
|
|
2532
|
+
if (totalBytes <= limits.maxBytes) return true;
|
|
2533
|
+
for (const [key, value] of blobStore) {
|
|
2534
|
+
if (totalBytes <= limits.maxBytes) break;
|
|
2535
|
+
if (key === blobId) continue;
|
|
2536
|
+
blobStore.delete(key);
|
|
2537
|
+
totalBytes -= value.byteLength;
|
|
2538
|
+
}
|
|
2539
|
+
return true;
|
|
2540
|
+
}
|
|
2541
|
+
|
|
2542
|
+
export function storeCursorBlobForTest(
|
|
2543
|
+
blobStore: Map<string, Uint8Array>,
|
|
2544
|
+
blobId: Uint8Array,
|
|
2545
|
+
blobData: Uint8Array,
|
|
2546
|
+
limits: { maxBytes: number },
|
|
2547
|
+
): boolean {
|
|
2548
|
+
return (
|
|
2549
|
+
blobId.byteLength === CURSOR_BLOB_ID_BYTES &&
|
|
2550
|
+
putCursorBlob(blobStore, Buffer.from(blobId).toString("hex"), blobData, limits)
|
|
2551
|
+
);
|
|
2552
|
+
}
|
|
2553
|
+
|
|
1194
2554
|
function sendShellStreamEvent(
|
|
1195
2555
|
h2Request: CursorRequestWriter,
|
|
1196
2556
|
execMsg: ExecServerMessage,
|
|
@@ -1230,6 +2590,8 @@ async function handleShellStreamArgs(
|
|
|
1230
2590
|
h2Request: CursorRequestWriter,
|
|
1231
2591
|
execHandlers: CursorExecHandlers | undefined,
|
|
1232
2592
|
onToolResult: CursorToolResultHandler | undefined,
|
|
2593
|
+
execSignal?: AbortSignal,
|
|
2594
|
+
markNonAbortable?: () => void,
|
|
1233
2595
|
): Promise<void> {
|
|
1234
2596
|
const normalizedWorkingDirectory = args.workingDirectory || process.cwd();
|
|
1235
2597
|
const normalizedArgs: ShellArgs = { ...args, workingDirectory: normalizedWorkingDirectory };
|
|
@@ -1249,6 +2611,49 @@ async function handleShellStreamArgs(
|
|
|
1249
2611
|
let stdoutBuffer = "";
|
|
1250
2612
|
let stderrBuffer = "";
|
|
1251
2613
|
let callbacksOpen = true;
|
|
2614
|
+
let pendingShellWriteBytes = 0;
|
|
2615
|
+
let shellWriteFailure: unknown;
|
|
2616
|
+
let shellWriteChain = Promise.resolve();
|
|
2617
|
+
const queueShellStreamEvent = (event: ShellStream["event"]): void => {
|
|
2618
|
+
if (shellWriteFailure || !callbacksOpen || !h2Request.isActive()) return;
|
|
2619
|
+
const frame = encodeExecClientMessageFrame(execMsg, {
|
|
2620
|
+
case: "shellStream",
|
|
2621
|
+
value: create(ShellStreamSchema, { event }),
|
|
2622
|
+
});
|
|
2623
|
+
if (pendingShellWriteBytes + frame.length > CURSOR_MAX_PENDING_SHELL_WRITE_BYTES) {
|
|
2624
|
+
const error = new Error(
|
|
2625
|
+
`Cursor shell output exceeded ${CURSOR_MAX_PENDING_SHELL_WRITE_BYTES} pending write bytes`,
|
|
2626
|
+
);
|
|
2627
|
+
shellWriteFailure = error;
|
|
2628
|
+
callbacksOpen = false;
|
|
2629
|
+
closeStalledCursorRequest(h2Request);
|
|
2630
|
+
shellWriteChain = shellWriteChain.then(() => {
|
|
2631
|
+
throw error;
|
|
2632
|
+
});
|
|
2633
|
+
shellWriteChain.catch(() => {});
|
|
2634
|
+
return;
|
|
2635
|
+
}
|
|
2636
|
+
pendingShellWriteBytes += frame.length;
|
|
2637
|
+
const queuedWrite = shellWriteChain
|
|
2638
|
+
.then(async () => {
|
|
2639
|
+
if (shellWriteFailure) throw shellWriteFailure;
|
|
2640
|
+
const writable = writeCursorFrame(h2Request, frame);
|
|
2641
|
+
if (writable) return;
|
|
2642
|
+
if (isClosedCursorRequest(h2Request)) {
|
|
2643
|
+
throw new Error("Cursor request closed while forwarding shell output");
|
|
2644
|
+
}
|
|
2645
|
+
await waitForCursorWriteDrain(h2Request);
|
|
2646
|
+
})
|
|
2647
|
+
.finally(() => {
|
|
2648
|
+
pendingShellWriteBytes -= frame.length;
|
|
2649
|
+
});
|
|
2650
|
+
shellWriteChain = queuedWrite;
|
|
2651
|
+
queuedWrite.catch(error => {
|
|
2652
|
+
shellWriteFailure ??= error;
|
|
2653
|
+
callbacksOpen = false;
|
|
2654
|
+
closeStalledCursorRequest(h2Request);
|
|
2655
|
+
});
|
|
2656
|
+
};
|
|
1252
2657
|
const unregisterShellGate = h2Request.registerShellGate(() => {
|
|
1253
2658
|
callbacksOpen = false;
|
|
1254
2659
|
if (stdoutFlushTimer) clearTimeout(stdoutFlushTimer);
|
|
@@ -1267,7 +2672,7 @@ async function handleShellStreamArgs(
|
|
|
1267
2672
|
const toSend = stdoutBuffer.slice(0, safeEnd);
|
|
1268
2673
|
const remaining = stdoutBuffer.slice(safeEnd);
|
|
1269
2674
|
if (toSend) {
|
|
1270
|
-
|
|
2675
|
+
queueShellStreamEvent({
|
|
1271
2676
|
case: "stdout",
|
|
1272
2677
|
value: create(ShellStreamStdoutSchema, { data: sanitizeText(toSend) }),
|
|
1273
2678
|
});
|
|
@@ -1286,7 +2691,7 @@ async function handleShellStreamArgs(
|
|
|
1286
2691
|
const toSend = stderrBuffer.slice(0, safeEnd);
|
|
1287
2692
|
const remaining = stderrBuffer.slice(safeEnd);
|
|
1288
2693
|
if (toSend) {
|
|
1289
|
-
|
|
2694
|
+
queueShellStreamEvent({
|
|
1290
2695
|
case: "stderr",
|
|
1291
2696
|
value: create(ShellStreamStderrSchema, { data: sanitizeText(toSend) }),
|
|
1292
2697
|
});
|
|
@@ -1349,11 +2754,15 @@ async function handleShellStreamArgs(
|
|
|
1349
2754
|
// Falls back to the batch shell handler otherwise.
|
|
1350
2755
|
const streamHandler = execHandlers?.shellStream?.bind(execHandlers);
|
|
1351
2756
|
const batchHandler = execHandlers?.shell?.bind(execHandlers);
|
|
1352
|
-
const handler = streamHandler
|
|
2757
|
+
const handler = streamHandler
|
|
2758
|
+
? (shellArgs: ShellArgs) => streamHandler(shellArgs, streamCallbacks, execSignal, markNonAbortable)
|
|
2759
|
+
: batchHandler
|
|
2760
|
+
? (shellArgs: ShellArgs) => batchHandler(shellArgs, execSignal, markNonAbortable)
|
|
2761
|
+
: undefined;
|
|
1353
2762
|
|
|
1354
2763
|
const { execResult } = await resolveExecHandler(
|
|
1355
2764
|
args as any,
|
|
1356
|
-
handler
|
|
2765
|
+
handler,
|
|
1357
2766
|
onToolResult,
|
|
1358
2767
|
toolResult => buildShellResultFromToolResult(normalizedArgs as any, toolResult),
|
|
1359
2768
|
reason =>
|
|
@@ -1372,6 +2781,8 @@ async function handleShellStreamArgs(
|
|
|
1372
2781
|
if (stderrFlushTimer) clearTimeout(stderrFlushTimer);
|
|
1373
2782
|
flushStdout();
|
|
1374
2783
|
flushStderr();
|
|
2784
|
+
await shellWriteChain;
|
|
2785
|
+
if (shellWriteFailure) throw shellWriteFailure;
|
|
1375
2786
|
|
|
1376
2787
|
sendShellStreamExitFromResult(h2Request, execMsg, sanitizedExecResult, sendBufferedOutput);
|
|
1377
2788
|
// Cursor can keep the turn pending when it receives only stream deltas.
|
|
@@ -1501,6 +2912,8 @@ async function handleExecServerMessage(
|
|
|
1501
2912
|
output: AssistantMessage,
|
|
1502
2913
|
stream: AssistantMessageEventStream,
|
|
1503
2914
|
requestContextRules: CursorRule[] = [],
|
|
2915
|
+
execSignal?: AbortSignal,
|
|
2916
|
+
markNonAbortable?: () => void,
|
|
1504
2917
|
): Promise<void> {
|
|
1505
2918
|
const execCase = execMsg.message.case;
|
|
1506
2919
|
log("exec", "dispatch", { execCase, execId: execMsg.execId, hasHandlers: !!execHandlers });
|
|
@@ -1542,6 +2955,8 @@ async function handleExecServerMessage(
|
|
|
1542
2955
|
toolResult => buildReadResultFromToolResult(args.path, toolResult),
|
|
1543
2956
|
reason => buildReadRejectedResult(args.path, reason),
|
|
1544
2957
|
error => buildReadErrorResult(args.path, error),
|
|
2958
|
+
execSignal,
|
|
2959
|
+
markNonAbortable,
|
|
1545
2960
|
);
|
|
1546
2961
|
sendExecClientMessage(h2Request, execMsg, "readResult", execResult);
|
|
1547
2962
|
return;
|
|
@@ -1555,6 +2970,8 @@ async function handleExecServerMessage(
|
|
|
1555
2970
|
toolResult => buildLsResultFromToolResult(args.path, toolResult),
|
|
1556
2971
|
reason => buildLsRejectedResult(args.path, reason),
|
|
1557
2972
|
error => buildLsErrorResult(args.path, error),
|
|
2973
|
+
execSignal,
|
|
2974
|
+
markNonAbortable,
|
|
1558
2975
|
);
|
|
1559
2976
|
sendExecClientMessage(h2Request, execMsg, "lsResult", execResult);
|
|
1560
2977
|
return;
|
|
@@ -1568,6 +2985,7 @@ async function handleExecServerMessage(
|
|
|
1568
2985
|
toolResult => buildGrepResultFromToolResult(args, toolResult),
|
|
1569
2986
|
reason => buildGrepErrorResult(reason),
|
|
1570
2987
|
error => buildGrepErrorResult(error),
|
|
2988
|
+
execSignal,
|
|
1571
2989
|
);
|
|
1572
2990
|
sendExecClientMessage(h2Request, execMsg, "grepResult", execResult);
|
|
1573
2991
|
return;
|
|
@@ -1590,6 +3008,8 @@ async function handleExecServerMessage(
|
|
|
1590
3008
|
),
|
|
1591
3009
|
reason => buildWriteRejectedResult(args.path, reason),
|
|
1592
3010
|
error => buildWriteErrorResult(args.path, error),
|
|
3011
|
+
execSignal,
|
|
3012
|
+
markNonAbortable,
|
|
1593
3013
|
);
|
|
1594
3014
|
sendExecClientMessage(h2Request, execMsg, "writeResult", execResult);
|
|
1595
3015
|
return;
|
|
@@ -1603,6 +3023,8 @@ async function handleExecServerMessage(
|
|
|
1603
3023
|
toolResult => buildDeleteResultFromToolResult(args.path, toolResult),
|
|
1604
3024
|
reason => buildDeleteRejectedResult(args.path, reason),
|
|
1605
3025
|
error => buildDeleteErrorResult(args.path, error),
|
|
3026
|
+
execSignal,
|
|
3027
|
+
markNonAbortable,
|
|
1606
3028
|
);
|
|
1607
3029
|
sendExecClientMessage(h2Request, execMsg, "deleteResult", execResult);
|
|
1608
3030
|
return;
|
|
@@ -1617,6 +3039,8 @@ async function handleExecServerMessage(
|
|
|
1617
3039
|
toolResult => buildShellResultFromToolResult(normalizedArgs, toolResult),
|
|
1618
3040
|
reason => buildShellRejectedResult(normalizedArgs.command, normalizedArgs.workingDirectory, reason),
|
|
1619
3041
|
error => buildShellFailureResult(normalizedArgs.command, normalizedArgs.workingDirectory, error),
|
|
3042
|
+
execSignal,
|
|
3043
|
+
markNonAbortable,
|
|
1620
3044
|
);
|
|
1621
3045
|
const sanitizedExecResult = sanitizeShellExecResult(execResult);
|
|
1622
3046
|
sendExecClientMessage(h2Request, execMsg, "shellResult", sanitizedExecResult);
|
|
@@ -1624,7 +3048,15 @@ async function handleExecServerMessage(
|
|
|
1624
3048
|
}
|
|
1625
3049
|
case "shellStreamArgs": {
|
|
1626
3050
|
const args = execMsg.message.value;
|
|
1627
|
-
await handleShellStreamArgs(
|
|
3051
|
+
await handleShellStreamArgs(
|
|
3052
|
+
args,
|
|
3053
|
+
execMsg,
|
|
3054
|
+
h2Request,
|
|
3055
|
+
execHandlers,
|
|
3056
|
+
onToolResult,
|
|
3057
|
+
execSignal,
|
|
3058
|
+
markNonAbortable,
|
|
3059
|
+
);
|
|
1628
3060
|
return;
|
|
1629
3061
|
}
|
|
1630
3062
|
case "backgroundShellSpawnArgs": {
|
|
@@ -1678,6 +3110,7 @@ async function handleExecServerMessage(
|
|
|
1678
3110
|
toolResult => buildDiagnosticsResultFromToolResult(args.path, toolResult),
|
|
1679
3111
|
reason => buildDiagnosticsRejectedResult(args.path, reason),
|
|
1680
3112
|
error => buildDiagnosticsErrorResult(args.path, error),
|
|
3113
|
+
execSignal,
|
|
1681
3114
|
);
|
|
1682
3115
|
sendExecClientMessage(h2Request, execMsg, "diagnosticsResult", execResult);
|
|
1683
3116
|
return;
|
|
@@ -1692,6 +3125,8 @@ async function handleExecServerMessage(
|
|
|
1692
3125
|
toolResult => buildMcpResultFromToolResult(mcpCall, toolResult),
|
|
1693
3126
|
_reason => buildMcpToolNotFoundResult(mcpCall),
|
|
1694
3127
|
error => buildMcpErrorResult(error),
|
|
3128
|
+
execSignal,
|
|
3129
|
+
markNonAbortable,
|
|
1695
3130
|
);
|
|
1696
3131
|
sendExecClientMessage(h2Request, execMsg, "mcpResult", execResult);
|
|
1697
3132
|
return;
|
|
@@ -1722,7 +3157,7 @@ async function handleExecServerMessage(
|
|
|
1722
3157
|
synthesizeCursorExecToolCall(output, stream, toolCallId, "read", {
|
|
1723
3158
|
path: piReadDisplayPath(args.path, args.offset, args.limit),
|
|
1724
3159
|
});
|
|
1725
|
-
const call = { args, toolCallId };
|
|
3160
|
+
const call = { args, toolCallId, signal: execSignal, markNonAbortable };
|
|
1726
3161
|
const { execResult } = await resolveExecHandler(
|
|
1727
3162
|
call,
|
|
1728
3163
|
execHandlers?.piRead?.bind(execHandlers),
|
|
@@ -1741,7 +3176,7 @@ async function handleExecServerMessage(
|
|
|
1741
3176
|
command: args.command,
|
|
1742
3177
|
timeout: piTimeout(args.timeout),
|
|
1743
3178
|
});
|
|
1744
|
-
const call = { args, toolCallId };
|
|
3179
|
+
const call = { args, toolCallId, signal: execSignal, markNonAbortable };
|
|
1745
3180
|
const { execResult } = await resolveExecHandler(
|
|
1746
3181
|
call,
|
|
1747
3182
|
execHandlers?.piBash?.bind(execHandlers),
|
|
@@ -1760,7 +3195,7 @@ async function handleExecServerMessage(
|
|
|
1760
3195
|
path: args.path,
|
|
1761
3196
|
edits: args.edits.map(edit => ({ old_text: edit.oldText, new_text: edit.newText })),
|
|
1762
3197
|
});
|
|
1763
|
-
const call = { args, toolCallId };
|
|
3198
|
+
const call = { args, toolCallId, signal: execSignal, markNonAbortable };
|
|
1764
3199
|
const { execResult } = await resolveExecHandler(
|
|
1765
3200
|
call,
|
|
1766
3201
|
execHandlers?.piEdit?.bind(execHandlers),
|
|
@@ -1779,7 +3214,7 @@ async function handleExecServerMessage(
|
|
|
1779
3214
|
path: args.path,
|
|
1780
3215
|
content: args.content,
|
|
1781
3216
|
});
|
|
1782
|
-
const call = { args, toolCallId };
|
|
3217
|
+
const call = { args, toolCallId, signal: execSignal, markNonAbortable };
|
|
1783
3218
|
const { execResult } = await resolveExecHandler(
|
|
1784
3219
|
call,
|
|
1785
3220
|
execHandlers?.piWrite?.bind(execHandlers),
|
|
@@ -1803,7 +3238,7 @@ async function handleExecServerMessage(
|
|
|
1803
3238
|
context: args.context,
|
|
1804
3239
|
limit: piLimit(args.limit),
|
|
1805
3240
|
});
|
|
1806
|
-
const call = { args, toolCallId };
|
|
3241
|
+
const call = { args, toolCallId, signal: execSignal };
|
|
1807
3242
|
const { execResult } = await resolveExecHandler(
|
|
1808
3243
|
call,
|
|
1809
3244
|
execHandlers?.piGrep?.bind(execHandlers),
|
|
@@ -1822,7 +3257,7 @@ async function handleExecServerMessage(
|
|
|
1822
3257
|
paths: [piJoinPath(args.path, args.pattern)],
|
|
1823
3258
|
limit: piLimit(args.limit),
|
|
1824
3259
|
});
|
|
1825
|
-
const call = { args, toolCallId };
|
|
3260
|
+
const call = { args, toolCallId, signal: execSignal };
|
|
1826
3261
|
const { execResult } = await resolveExecHandler(
|
|
1827
3262
|
call,
|
|
1828
3263
|
execHandlers?.piFind?.bind(execHandlers),
|
|
@@ -1838,7 +3273,7 @@ async function handleExecServerMessage(
|
|
|
1838
3273
|
const args = execMsg.message.value;
|
|
1839
3274
|
const toolCallId = crypto.randomUUID();
|
|
1840
3275
|
synthesizeCursorExecToolCall(output, stream, toolCallId, "read", { path: piLsPath(args.path) });
|
|
1841
|
-
const call = { args, toolCallId };
|
|
3276
|
+
const call = { args, toolCallId, signal: execSignal, markNonAbortable };
|
|
1842
3277
|
const { execResult } = await resolveExecHandler(
|
|
1843
3278
|
call,
|
|
1844
3279
|
execHandlers?.piLs?.bind(execHandlers),
|
|
@@ -1918,10 +3353,18 @@ function sendExecClientMessage<TCase extends NonNullable<ExecClientMessage["mess
|
|
|
1918
3353
|
messageCase: TCase,
|
|
1919
3354
|
value: Extract<ExecClientMessage["message"], { case: TCase }>["value"],
|
|
1920
3355
|
): void {
|
|
3356
|
+
writeCursorFrame(
|
|
3357
|
+
h2Request,
|
|
3358
|
+
encodeExecClientMessageFrame(execMsg, { case: messageCase, value } as ExecClientMessage["message"]),
|
|
3359
|
+
);
|
|
3360
|
+
log("execClientMessage", messageCase, value);
|
|
3361
|
+
}
|
|
3362
|
+
|
|
3363
|
+
function encodeExecClientMessageFrame(execMsg: ExecServerMessage, message: ExecClientMessage["message"]): Buffer {
|
|
1921
3364
|
const execClientMessage = create(ExecClientMessageSchema, {
|
|
1922
3365
|
id: execMsg.id,
|
|
1923
3366
|
execId: execMsg.execId,
|
|
1924
|
-
message
|
|
3367
|
+
message,
|
|
1925
3368
|
});
|
|
1926
3369
|
|
|
1927
3370
|
const clientMessage = create(AgentClientMessageSchema, {
|
|
@@ -1929,9 +3372,7 @@ function sendExecClientMessage<TCase extends NonNullable<ExecClientMessage["mess
|
|
|
1929
3372
|
});
|
|
1930
3373
|
|
|
1931
3374
|
const responseBytes = toBinary(AgentClientMessageSchema, clientMessage);
|
|
1932
|
-
|
|
1933
|
-
|
|
1934
|
-
log("execClientMessage", messageCase, value);
|
|
3375
|
+
return frameConnectMessage(responseBytes);
|
|
1935
3376
|
}
|
|
1936
3377
|
|
|
1937
3378
|
function sendExecClientThrow(
|
|
@@ -1949,7 +3390,7 @@ function sendExecClientThrow(
|
|
|
1949
3390
|
const clientMessage = create(AgentClientMessageSchema, {
|
|
1950
3391
|
message: { case: "execClientControlMessage", value: controlMessage },
|
|
1951
3392
|
});
|
|
1952
|
-
h2Request
|
|
3393
|
+
writeCursorFrame(h2Request, frameConnectMessage(toBinary(AgentClientMessageSchema, clientMessage)));
|
|
1953
3394
|
sendExecClientStreamClose(h2Request, execMsg);
|
|
1954
3395
|
}
|
|
1955
3396
|
|
|
@@ -1966,25 +3407,33 @@ function sendExecClientStreamClose(h2Request: CursorRequestWriter, execMsg: Exec
|
|
|
1966
3407
|
message: { case: "execClientControlMessage", value: closeMessage },
|
|
1967
3408
|
});
|
|
1968
3409
|
const responseBytes = toBinary(AgentClientMessageSchema, clientMessage);
|
|
1969
|
-
h2Request
|
|
3410
|
+
writeCursorFrame(h2Request, frameConnectMessage(responseBytes));
|
|
1970
3411
|
log("execClientControl", "streamClose", { id: execMsg.id, execId: execMsg.execId });
|
|
1971
3412
|
}
|
|
1972
3413
|
|
|
1973
3414
|
/** Exported for tests: verifies handler is invoked with correct `this` when passed as bound. */
|
|
1974
3415
|
export async function resolveExecHandler<TArgs, TResult>(
|
|
1975
3416
|
args: TArgs,
|
|
1976
|
-
handler:
|
|
3417
|
+
handler:
|
|
3418
|
+
| ((
|
|
3419
|
+
args: TArgs,
|
|
3420
|
+
signal?: AbortSignal,
|
|
3421
|
+
markNonAbortable?: () => void,
|
|
3422
|
+
) => Promise<CursorExecHandlerResult<TResult>>)
|
|
3423
|
+
| undefined,
|
|
1977
3424
|
onToolResult: CursorToolResultHandler | undefined,
|
|
1978
3425
|
buildFromToolResult: (toolResult: ToolResultMessage) => TResult,
|
|
1979
3426
|
buildRejected: (reason: string) => TResult,
|
|
1980
3427
|
buildError: (error: string) => TResult,
|
|
3428
|
+
signal?: AbortSignal,
|
|
3429
|
+
markNonAbortable?: () => void,
|
|
1981
3430
|
): Promise<{ execResult: TResult; toolResult?: ToolResultMessage }> {
|
|
1982
3431
|
if (!handler) {
|
|
1983
3432
|
return { execResult: buildRejected("Tool not available") };
|
|
1984
3433
|
}
|
|
1985
3434
|
|
|
1986
3435
|
try {
|
|
1987
|
-
const handlerResult = await handler(args);
|
|
3436
|
+
const handlerResult = await handler(args, signal, markNonAbortable);
|
|
1988
3437
|
const { execResult, toolResult } = splitExecHandlerResult(handlerResult);
|
|
1989
3438
|
const finalToolResult = await applyToolResultHandler(toolResult, onToolResult);
|
|
1990
3439
|
|
|
@@ -1996,28 +3445,73 @@ export async function resolveExecHandler<TArgs, TResult>(
|
|
|
1996
3445
|
}
|
|
1997
3446
|
return { execResult: buildRejected("Tool returned no result") };
|
|
1998
3447
|
} catch (error) {
|
|
3448
|
+
if (error instanceof CursorExecAdmissionClosedError) throw error;
|
|
1999
3449
|
const message = error instanceof Error ? error.message : String(error);
|
|
2000
3450
|
return { execResult: buildError(message) };
|
|
2001
3451
|
}
|
|
2002
3452
|
}
|
|
2003
3453
|
|
|
2004
3454
|
/** Exported for deterministic coverage of ordered server-message handling. */
|
|
2005
|
-
export function createCursorMessageQueueForTest(
|
|
2006
|
-
|
|
3455
|
+
export function createCursorMessageQueueForTest(
|
|
3456
|
+
onError?: (error: unknown) => void,
|
|
3457
|
+
maxPendingBytes = CURSOR_MAX_QUEUED_SERVER_BYTES,
|
|
3458
|
+
): {
|
|
3459
|
+
enqueue(handler: () => void | Promise<void>, byteSize?: number): Promise<void>;
|
|
2007
3460
|
drain(): Promise<void>;
|
|
3461
|
+
pending(): number;
|
|
3462
|
+
pendingBytes(): number;
|
|
2008
3463
|
} {
|
|
2009
3464
|
let chain = Promise.resolve();
|
|
3465
|
+
let pending = 0;
|
|
3466
|
+
let pendingBytes = 0;
|
|
3467
|
+
let closed = false;
|
|
3468
|
+
let hasAdmittedTask = false;
|
|
2010
3469
|
return {
|
|
2011
|
-
enqueue(handler) {
|
|
2012
|
-
|
|
2013
|
-
|
|
3470
|
+
enqueue(handler, byteSize = 0) {
|
|
3471
|
+
if (closed) return Promise.reject(new Error("Cursor server-message queue is closed"));
|
|
3472
|
+
if (pending >= CURSOR_MAX_PENDING_SERVER_MESSAGES) {
|
|
3473
|
+
const error = new Error("Cursor server-message queue exceeded its bounded capacity");
|
|
3474
|
+
closed = true;
|
|
3475
|
+
onError?.(error);
|
|
3476
|
+
return Promise.reject(error);
|
|
3477
|
+
}
|
|
3478
|
+
if (byteSize < 0 || pendingBytes + byteSize > maxPendingBytes) {
|
|
3479
|
+
const error = new Error("Cursor server-message queue exceeded its bounded byte capacity");
|
|
3480
|
+
closed = true;
|
|
2014
3481
|
onError?.(error);
|
|
3482
|
+
return Promise.reject(error);
|
|
3483
|
+
}
|
|
3484
|
+
pending += 1;
|
|
3485
|
+
pendingBytes += byteSize;
|
|
3486
|
+
let result: Promise<void>;
|
|
3487
|
+
if (!hasAdmittedTask) {
|
|
3488
|
+
hasAdmittedTask = true;
|
|
3489
|
+
try {
|
|
3490
|
+
result = Promise.resolve(handler());
|
|
3491
|
+
} catch (error) {
|
|
3492
|
+
result = Promise.reject(error);
|
|
3493
|
+
}
|
|
3494
|
+
} else {
|
|
3495
|
+
result = chain.then(handler);
|
|
3496
|
+
}
|
|
3497
|
+
const accounting = result.finally(() => {
|
|
3498
|
+
pending -= 1;
|
|
3499
|
+
pendingBytes -= byteSize;
|
|
2015
3500
|
});
|
|
2016
|
-
|
|
3501
|
+
chain = accounting.catch(error => {
|
|
3502
|
+
onError?.(error);
|
|
3503
|
+
});
|
|
3504
|
+
return accounting;
|
|
2017
3505
|
},
|
|
2018
3506
|
drain() {
|
|
2019
3507
|
return chain;
|
|
2020
3508
|
},
|
|
3509
|
+
pending() {
|
|
3510
|
+
return pending;
|
|
3511
|
+
},
|
|
3512
|
+
pendingBytes() {
|
|
3513
|
+
return pendingBytes;
|
|
3514
|
+
},
|
|
2021
3515
|
};
|
|
2022
3516
|
}
|
|
2023
3517
|
|
|
@@ -2826,7 +4320,7 @@ function synthesizeCursorExecToolCall(
|
|
|
2826
4320
|
arguments: cursorJsonSafeValue(args) as Record<string, unknown>,
|
|
2827
4321
|
index: output.content.length,
|
|
2828
4322
|
kind: "cursor-exec",
|
|
2829
|
-
[
|
|
4323
|
+
[kProviderResolvedToolCall]: true,
|
|
2830
4324
|
};
|
|
2831
4325
|
output.content.push(block);
|
|
2832
4326
|
const contentIndex = output.content.length - 1;
|
|
@@ -2999,32 +4493,20 @@ function handleConversationCheckpointUpdate(
|
|
|
2999
4493
|
return;
|
|
3000
4494
|
}
|
|
3001
4495
|
const previousUsedTokens = usageState.conversationUsedTokens;
|
|
3002
|
-
// `used_tokens` counts the whole conversation, so it is prompt-side usage and
|
|
3003
|
-
// must not be attributed to this turn's output. Checkpoints can arrive while
|
|
3004
|
-
// output is still streaming; the split is applied once the stream finalizes.
|
|
3005
4496
|
usageState.conversationUsedTokens = usedTokens;
|
|
3006
|
-
usageState.checkpointOutputTokens =
|
|
3007
|
-
usageState.hasConversationCheckpoint && usedTokens < previousUsedTokens ? 0 : output.usage.output;
|
|
4497
|
+
usageState.checkpointOutputTokens = usedTokens < previousUsedTokens ? 0 : output.usage.output;
|
|
3008
4498
|
usageState.hasConversationCheckpoint = true;
|
|
3009
4499
|
}
|
|
3010
4500
|
|
|
3011
|
-
/**
|
|
3012
|
-
* Cursor streams output tokens as deltas and reports whole-conversation
|
|
3013
|
-
* consumption separately as `ConversationTokenDetails.used_tokens`. Derive
|
|
3014
|
-
* prompt tokens from the difference so context accounting and compaction see a
|
|
3015
|
-
* real prompt size instead of zero.
|
|
3016
|
-
*/
|
|
4501
|
+
/** Derive prompt usage from Cursor's whole-conversation checkpoint total. */
|
|
3017
4502
|
export function finalizeCursorUsage(output: AssistantMessage, usageState: UsageState): void {
|
|
3018
4503
|
const used = usageState.conversationUsedTokens;
|
|
3019
|
-
if (!usageState.hasConversationCheckpoint && used <= 0)
|
|
3020
|
-
return;
|
|
3021
|
-
}
|
|
4504
|
+
if (!usageState.hasConversationCheckpoint && used <= 0) return;
|
|
3022
4505
|
const outputIncludedInSnapshot = usageState.hasConversationCheckpoint ? usageState.checkpointOutputTokens : 0;
|
|
3023
4506
|
output.usage.input = Math.max(0, used - outputIncludedInSnapshot);
|
|
3024
4507
|
output.usage.totalTokens = output.usage.input + output.usage.output;
|
|
3025
4508
|
}
|
|
3026
4509
|
|
|
3027
|
-
/** Exposes {@link finalizeCursorUsage} for tests without a live HTTP/2 stream. */
|
|
3028
4510
|
export function finalizeCursorUsageForTest(
|
|
3029
4511
|
usedTokens: number,
|
|
3030
4512
|
outputTokens: number,
|
|
@@ -3054,7 +4536,9 @@ function createBlobId(data: Uint8Array): Uint8Array {
|
|
|
3054
4536
|
|
|
3055
4537
|
function storeCursorBlob(blobStore: Map<string, Uint8Array>, data: Uint8Array): Uint8Array {
|
|
3056
4538
|
const blobId = createBlobId(data);
|
|
3057
|
-
|
|
4539
|
+
// Request construction is the larger writer of the two. Charging it to the
|
|
4540
|
+
// same budget is what makes the budget describe the real map.
|
|
4541
|
+
putCursorBlob(blobStore, Buffer.from(blobId).toString("hex"), data);
|
|
3058
4542
|
return blobId;
|
|
3059
4543
|
}
|
|
3060
4544
|
|
|
@@ -3068,26 +4552,40 @@ function readCursorBlob(blobStore: Map<string, Uint8Array>, blobId: Uint8Array):
|
|
|
3068
4552
|
|
|
3069
4553
|
const CURSOR_NATIVE_TOOL_NAMES = new Set(["bash", "read", "write", "delete", "ls", "grep", "lsp", "todo_write"]);
|
|
3070
4554
|
|
|
3071
|
-
|
|
3072
|
-
|
|
3073
|
-
|
|
3074
|
-
|
|
4555
|
+
interface CursorWireToolIdentity {
|
|
4556
|
+
name: string;
|
|
4557
|
+
description: string;
|
|
4558
|
+
inputSchema: JsonValue;
|
|
4559
|
+
}
|
|
3075
4560
|
|
|
3076
|
-
|
|
3077
|
-
if (
|
|
3078
|
-
return [];
|
|
3079
|
-
}
|
|
4561
|
+
function buildCursorWireToolIdentities(tools: Tool[] | undefined): CursorWireToolIdentity[] {
|
|
4562
|
+
if (!tools || tools.length === 0) return [];
|
|
3080
4563
|
|
|
3081
|
-
return
|
|
3082
|
-
|
|
3083
|
-
|
|
3084
|
-
|
|
3085
|
-
|
|
3086
|
-
:
|
|
3087
|
-
|
|
4564
|
+
return tools
|
|
4565
|
+
.filter(tool => !CURSOR_NATIVE_TOOL_NAMES.has(tool.name))
|
|
4566
|
+
.map(tool => {
|
|
4567
|
+
const jsonSchema = flattenToolRootCombinators(toolWireSchema(tool));
|
|
4568
|
+
return {
|
|
4569
|
+
name: tool.name,
|
|
4570
|
+
description: tool.description || "",
|
|
4571
|
+
inputSchema:
|
|
4572
|
+
jsonSchema && typeof jsonSchema === "object"
|
|
4573
|
+
? (jsonSchema as JsonValue)
|
|
4574
|
+
: { type: "object", properties: {}, required: [] },
|
|
4575
|
+
};
|
|
4576
|
+
});
|
|
4577
|
+
}
|
|
4578
|
+
|
|
4579
|
+
function buildCursorUsageToolsKey(tools: Tool[] | undefined): string {
|
|
4580
|
+
return hashCursorConversationValue(buildCursorWireToolIdentities(tools));
|
|
4581
|
+
}
|
|
4582
|
+
|
|
4583
|
+
function buildMcpToolDefinitions(tools: Tool[] | undefined): McpToolDefinition[] {
|
|
4584
|
+
return buildCursorWireToolIdentities(tools).map(tool => {
|
|
4585
|
+
const inputSchema = toBinary(ValueSchema, fromJson(ValueSchema, tool.inputSchema));
|
|
3088
4586
|
return create(McpToolDefinitionSchema, {
|
|
3089
4587
|
name: tool.name,
|
|
3090
|
-
description: tool.description
|
|
4588
|
+
description: tool.description,
|
|
3091
4589
|
providerIdentifier: "pi-agent",
|
|
3092
4590
|
toolName: tool.name,
|
|
3093
4591
|
inputSchema,
|
|
@@ -3368,41 +4866,9 @@ function buildConversationTurns(messages: Message[], blobStore: Map<string, Uint
|
|
|
3368
4866
|
return turns;
|
|
3369
4867
|
}
|
|
3370
4868
|
|
|
3371
|
-
|
|
3372
|
-
|
|
3373
|
-
|
|
3374
|
-
options: CursorOptions | undefined,
|
|
3375
|
-
): CursorUsageContext {
|
|
3376
|
-
return {
|
|
3377
|
-
modelKey: hashCursorUsageValue({ provider: model.provider, id: model.id, wireModelId: model.wireModelId }),
|
|
3378
|
-
systemPromptKey: hashCursorUsageValue(context.systemPrompt ?? []),
|
|
3379
|
-
customSystemPromptKey: hashCursorUsageValue(options?.customSystemPrompt ?? ""),
|
|
3380
|
-
toolsKey: hashCursorUsageValue(context.tools ?? []),
|
|
3381
|
-
messageKeys: context.messages.map(message => hashCursorUsageMessage(message)),
|
|
3382
|
-
};
|
|
3383
|
-
}
|
|
3384
|
-
|
|
3385
|
-
function hashCursorUsageMessage(message: { role: string; content: unknown }): string {
|
|
3386
|
-
return hashCursorUsageValue({ role: message.role, content: message.content });
|
|
3387
|
-
}
|
|
3388
|
-
|
|
3389
|
-
function hashCursorUsageValue(value: unknown): string {
|
|
3390
|
-
return createHash("sha256")
|
|
3391
|
-
.update(JSON.stringify(value) ?? "")
|
|
3392
|
-
.digest("hex");
|
|
3393
|
-
}
|
|
3394
|
-
|
|
3395
|
-
function canReuseCursorUsageContext(previous: CursorUsageContext | undefined, current: CursorUsageContext): boolean {
|
|
3396
|
-
if (
|
|
3397
|
-
!previous ||
|
|
3398
|
-
previous.modelKey !== current.modelKey ||
|
|
3399
|
-
previous.systemPromptKey !== current.systemPromptKey ||
|
|
3400
|
-
previous.customSystemPromptKey !== current.customSystemPromptKey ||
|
|
3401
|
-
previous.toolsKey !== current.toolsKey
|
|
3402
|
-
)
|
|
3403
|
-
return false;
|
|
3404
|
-
if (previous.messageKeys.length > current.messageKeys.length) return false;
|
|
3405
|
-
return previous.messageKeys.every((key, index) => key === current.messageKeys[index]);
|
|
4869
|
+
/** Exported for regression coverage of the tool usage-cache identity boundary. */
|
|
4870
|
+
export function buildCursorUsageToolsKeyForTest(tools: Tool[]): string {
|
|
4871
|
+
return buildCursorUsageToolsKey(tools);
|
|
3406
4872
|
}
|
|
3407
4873
|
|
|
3408
4874
|
/** Exported for tests: decodes Cursor history blobs built from conversation messages. */
|
|
@@ -3459,6 +4925,56 @@ function extractImages(content: (TextContent | ImageContent)[]) {
|
|
|
3459
4925
|
);
|
|
3460
4926
|
}
|
|
3461
4927
|
|
|
4928
|
+
function buildCursorConversationContext(
|
|
4929
|
+
context: Context,
|
|
4930
|
+
model: Model<"cursor-agent">,
|
|
4931
|
+
options: CursorOptions | undefined,
|
|
4932
|
+
baseUrl: string,
|
|
4933
|
+
apiKey: string,
|
|
4934
|
+
): CursorConversationContext {
|
|
4935
|
+
return {
|
|
4936
|
+
endpointKey: hashCursorConversationValue(baseUrl),
|
|
4937
|
+
credentialKey: hashCursorConversationValue({ apiKey, authCredentialType: options?.authCredentialType }),
|
|
4938
|
+
modelKey: hashCursorConversationValue({
|
|
4939
|
+
provider: model.provider,
|
|
4940
|
+
id: model.id,
|
|
4941
|
+
wireModelId: model.wireModelId,
|
|
4942
|
+
}),
|
|
4943
|
+
systemPromptKey: hashCursorConversationValue(context.systemPrompt ?? []),
|
|
4944
|
+
customSystemPromptKey: hashCursorConversationValue(options?.customSystemPrompt ?? ""),
|
|
4945
|
+
toolsKey: buildCursorUsageToolsKey(context.tools),
|
|
4946
|
+
messageKeys: context.messages.map(hashCursorConversationMessage),
|
|
4947
|
+
};
|
|
4948
|
+
}
|
|
4949
|
+
|
|
4950
|
+
function hashCursorConversationMessage(message: { role: string; content: unknown }): string {
|
|
4951
|
+
return hashCursorConversationValue({ role: message.role, content: message.content });
|
|
4952
|
+
}
|
|
4953
|
+
|
|
4954
|
+
function hashCursorConversationValue(value: unknown): string {
|
|
4955
|
+
return createHash("sha256")
|
|
4956
|
+
.update(JSON.stringify(value) ?? "")
|
|
4957
|
+
.digest("hex");
|
|
4958
|
+
}
|
|
4959
|
+
|
|
4960
|
+
function canReuseCursorConversationContext(
|
|
4961
|
+
previous: CursorConversationContext,
|
|
4962
|
+
current: CursorConversationContext,
|
|
4963
|
+
): boolean {
|
|
4964
|
+
if (
|
|
4965
|
+
previous.endpointKey !== current.endpointKey ||
|
|
4966
|
+
previous.credentialKey !== current.credentialKey ||
|
|
4967
|
+
previous.modelKey !== current.modelKey ||
|
|
4968
|
+
previous.systemPromptKey !== current.systemPromptKey ||
|
|
4969
|
+
previous.customSystemPromptKey !== current.customSystemPromptKey ||
|
|
4970
|
+
previous.toolsKey !== current.toolsKey ||
|
|
4971
|
+
previous.messageKeys.length > current.messageKeys.length
|
|
4972
|
+
) {
|
|
4973
|
+
return false;
|
|
4974
|
+
}
|
|
4975
|
+
return previous.messageKeys.every((key, index) => key === current.messageKeys[index]);
|
|
4976
|
+
}
|
|
4977
|
+
|
|
3462
4978
|
async function buildGrpcRequest(
|
|
3463
4979
|
model: Model<"cursor-agent">,
|
|
3464
4980
|
context: Context,
|